Skip to content

Commit a7816b2

Browse files
Sai Sridharclaude
andcommitted
Global error handling: persistent UI states + readable messages across all pages
Infrastructure: - lib/apiError.ts: shared apiErrorMessage() extracts FastAPI detail from axios responses, falls back to err.message, then a readable fallback string - components/PageError.tsx: reusable full-page error with WifiOff icon + Retry button, styled for both light and dark mode Pages fixed (initial data load): - revenue, relationships, knowledge, sequences, quotes, crm: add loadError state; on load failure show <PageError> with retry instead of disappearing toast leaving user looking at blank page - billing, actions, settings: SWR hooks already carry error state; now render <PageError> instead of silently rendering broken UI All mutation catches across 8 pages now use apiErrorMessage() so backend validation messages (e.g. "Product unavailable", "Invalid token") surface directly instead of generic "Failed to X" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fe05367 commit a7816b2

13 files changed

Lines changed: 197 additions & 95 deletions

File tree

frontend/components/PageError.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { WifiOff, RefreshCw } from 'lucide-react';
2+
3+
interface PageErrorProps {
4+
message?: string;
5+
onRetry?: () => void;
6+
}
7+
8+
export default function PageError({ message = 'Failed to load data', onRetry }: PageErrorProps) {
9+
return (
10+
<div className="flex flex-col items-center justify-center py-24 text-center">
11+
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30 mb-4">
12+
<WifiOff className="h-6 w-6 text-red-400" />
13+
</div>
14+
<p className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{message}</p>
15+
<p className="text-sm text-gray-500 dark:text-gray-400 mb-5">Check your connection or try again.</p>
16+
{onRetry && (
17+
<button
18+
onClick={onRetry}
19+
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors shadow-sm"
20+
>
21+
<RefreshCw className="h-4 w-4" />
22+
Retry
23+
</button>
24+
)}
25+
</div>
26+
);
27+
}

frontend/lib/apiError.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function apiErrorMessage(err: unknown, fallback = 'Something went wrong. Please try again.'): string {
2+
if (err && typeof err === 'object' && 'response' in err) {
3+
const res = (err as { response?: { data?: { detail?: string; message?: string } } }).response;
4+
if (res?.data?.detail) return res.data.detail;
5+
if (res?.data?.message) return res.data.message;
6+
}
7+
if (err instanceof Error) return err.message;
8+
return fallback;
9+
}

frontend/pages/actions/index.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import CreateTaskModal from '@/components/CreateTaskModal';
1010
import TaskDetailModal from '@/components/TaskDetailModal';
1111
import LoadingSpinner from '@/components/LoadingSpinner';
1212
import EmptyState from '@/components/EmptyState';
13+
import PageError from '@/components/PageError';
1314
import { useActions } from '@/lib/hooks';
1415
import { actionsApi } from '@/lib/api';
16+
import { apiErrorMessage } from '@/lib/apiError';
1517
import type { Action, ActionPriority } from '@/lib/types';
1618
import clsx from 'clsx';
1719

@@ -33,7 +35,7 @@ export default function ActionsPage() {
3335
if (!sessionLoading && !session) router.replace('/auth/signin');
3436
}, [session, sessionLoading, router]);
3537

36-
const { data, isLoading } = useActions({});
38+
const { data, isLoading, error: actionsError, mutate: reloadActions } = useActions({});
3739

3840
useEffect(() => {
3941
if (data) setActions(data);
@@ -47,26 +49,31 @@ export default function ActionsPage() {
4749
deadline: updated.deadline,
4850
});
4951
setActions((prev) => prev.map((a) => (a.id === result.id ? { ...a, ...result } : a)));
50-
} catch {
51-
toast.error('Failed to update task');
52+
} catch (err) {
53+
toast.error(apiErrorMessage(err, 'Failed to update task'));
5254
}
5355
};
5456

5557
const handleDelete = async (id: string) => {
5658
try {
5759
await actionsApi.deleteAction(id);
5860
setActions((prev) => prev.filter((a) => a.id !== id));
59-
} catch {
60-
toast.error('Failed to delete task');
61+
} catch (err) {
62+
toast.error(apiErrorMessage(err, 'Failed to delete task'));
6163
}
6264
};
6365

6466
const handleCreate = (action: Action) => {
6567
setActions((prev) => [action, ...prev]);
6668
};
6769

68-
if (sessionLoading) return <LoadingSpinner fullPage />;
70+
if (sessionLoading || isLoading) return <LoadingSpinner fullPage />;
6971
if (!session) return null;
72+
if (actionsError) return (
73+
<Layout title="Actions">
74+
<PageError message="Couldn't load actions" onRetry={() => reloadActions()} />
75+
</Layout>
76+
);
7077

7178
// Filter by priority
7279
const filtered =

frontend/pages/billing/index.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616
import toast from 'react-hot-toast';
1717
import Layout from '@/components/Layout';
1818
import LoadingSpinner from '@/components/LoadingSpinner';
19+
import PageError from '@/components/PageError';
1920
import { useBillingStatus } from '@/lib/hooks';
2021
import { billingApi } from '@/lib/api';
22+
import { apiErrorMessage } from '@/lib/apiError';
2123
import type { PlanId, Plan } from '@/lib/types';
2224
import clsx from 'clsx';
2325

@@ -120,6 +122,11 @@ export default function BillingPage() {
120122

121123
if (sessionLoading || billingLoading) return <LoadingSpinner fullPage />;
122124
if (!session) return null;
125+
if (billingError) return (
126+
<Layout title="Billing">
127+
<PageError message="Couldn't load billing status" onRetry={() => window.location.reload()} />
128+
</Layout>
129+
);
123130

124131
const handleUpgrade = async (planId: PlanId) => {
125132
if (planId === 'free') return;
@@ -128,9 +135,7 @@ export default function BillingPage() {
128135
const { checkout_url } = await billingApi.createCheckoutSession(planId, 'monthly');
129136
window.location.href = checkout_url;
130137
} catch (err: unknown) {
131-
const detail =
132-
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
133-
const msg = detail || (err instanceof Error ? err.message : 'Failed to start checkout. Please try again.');
138+
const msg = apiErrorMessage(err, 'Failed to start checkout. Please try again.');
134139
setCheckoutError(msg);
135140
toast.error(msg, { duration: 8000 });
136141
console.error('[checkout]', err);

frontend/pages/crm/index.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import {
2020
import { formatDistanceToNow } from 'date-fns';
2121
import clsx from 'clsx';
2222
import Layout from '@/components/Layout';
23+
import PageError from '@/components/PageError';
2324
import { contactsApi } from '@/lib/api';
25+
import { apiErrorMessage } from '@/lib/apiError';
2426
import type { ContactProfile, ContactDetail } from '@/lib/types';
2527
import toast from 'react-hot-toast';
2628

@@ -683,6 +685,7 @@ export default function CRMPage() {
683685
const [activeTab, setActiveTab] = useState<'contacts' | 'pipeline'>('contacts');
684686
const [contacts, setContacts] = useState<ContactProfile[]>([]);
685687
const [loading, setLoading] = useState(true);
688+
const [loadError, setLoadError] = useState(false);
686689
const [search, setSearch] = useState('');
687690
const [selectedContact, setSelectedContact] = useState<ContactDetail | null>(null);
688691
const [detailLoading, setDetailLoading] = useState(false);
@@ -697,8 +700,9 @@ export default function CRMPage() {
697700
setLoading(true);
698701
const data = await contactsApi.getContacts(q || undefined);
699702
setContacts(data);
700-
} catch {
701-
toast.error('Failed to load contacts');
703+
} catch (err) {
704+
setLoadError(true);
705+
toast.error(apiErrorMessage(err, 'Failed to load contacts'));
702706
} finally {
703707
setLoading(false);
704708
}
@@ -724,8 +728,8 @@ export default function CRMPage() {
724728
try {
725729
const detail = await contactsApi.getContact(contact.email);
726730
setSelectedContact(detail);
727-
} catch {
728-
toast.error('Failed to load contact details');
731+
} catch (err) {
732+
toast.error(apiErrorMessage(err, 'Failed to load contact details'));
729733
setActiveProfile(null);
730734
} finally {
731735
setDetailLoading(false);
@@ -739,6 +743,11 @@ export default function CRMPage() {
739743
}, []);
740744

741745
if (sessionLoading) return null;
746+
if (loadError && contacts.length === 0 && !search) return (
747+
<Layout title="CRM">
748+
<PageError message="Couldn't load contacts" onRetry={() => { setLoadError(false); loadContacts(); }} />
749+
</Layout>
750+
);
742751

743752
return (
744753
<>

frontend/pages/knowledge/index.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { BookOpen, Search, Zap, Trash2, Loader2, Tag } from 'lucide-react';
66
import toast from 'react-hot-toast';
77
import Layout from '@/components/Layout';
88
import LoadingSpinner from '@/components/LoadingSpinner';
9+
import PageError from '@/components/PageError';
910
import { knowledgeApi } from '@/lib/api';
11+
import { apiErrorMessage } from '@/lib/apiError';
1012
import type { KnowledgeEntry } from '@/lib/types';
1113

1214
const TYPE_COLORS: Record<string, string> = {
@@ -26,6 +28,7 @@ export default function KnowledgePage() {
2628
const { session, isLoading: sessionLoading } = useSessionContext();
2729
const [entries, setEntries] = useState<KnowledgeEntry[]>([]);
2830
const [loading, setLoading] = useState(true);
31+
const [loadError, setLoadError] = useState(false);
2932
const [extracting, setExtracting] = useState(false);
3033
const [query, setQuery] = useState('');
3134
const [typeFilter, setTypeFilter] = useState('');
@@ -40,8 +43,9 @@ export default function KnowledgePage() {
4043
try {
4144
const data = await knowledgeApi.getAll(q || undefined, type || undefined);
4245
setEntries(data.entries || []);
43-
} catch {
44-
toast.error('Failed to load knowledge base');
46+
} catch (err) {
47+
setLoadError(true);
48+
toast.error(apiErrorMessage(err, 'Failed to load knowledge base'));
4549
} finally {
4650
setLoading(false);
4751
}
@@ -60,8 +64,8 @@ export default function KnowledgePage() {
6064
const result = await knowledgeApi.bulkExtract();
6165
toast.success(`Extracted ${result.extracted} knowledge entries`);
6266
await load(query, typeFilter);
63-
} catch {
64-
toast.error('Extraction failed');
67+
} catch (err) {
68+
toast.error(apiErrorMessage(err, 'Extraction failed'));
6569
} finally {
6670
setExtracting(false);
6771
}
@@ -72,14 +76,19 @@ export default function KnowledgePage() {
7276
try {
7377
await knowledgeApi.delete(id);
7478
setEntries(prev => prev.filter(e => e.id !== id));
75-
} catch {
76-
toast.error('Delete failed');
79+
} catch (err) {
80+
toast.error(apiErrorMessage(err, 'Delete failed'));
7781
} finally {
7882
setDeletingId(null);
7983
}
8084
};
8185

8286
if (sessionLoading || !session) return <LoadingSpinner fullPage />;
87+
if (loadError && entries.length === 0 && !query && !typeFilter) return (
88+
<Layout title="Knowledge Base">
89+
<PageError message="Couldn't load knowledge base" onRetry={() => { setLoadError(false); load(); }} />
90+
</Layout>
91+
);
8392

8493
return (
8594
<>

frontend/pages/quotes/index.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { formatDistanceToNow } from 'date-fns';
77
import toast from 'react-hot-toast';
88
import Layout from '@/components/Layout';
99
import LoadingSpinner from '@/components/LoadingSpinner';
10+
import PageError from '@/components/PageError';
1011
import { quotesApi } from '@/lib/api';
12+
import { apiErrorMessage } from '@/lib/apiError';
1113
import type { Quote } from '@/lib/types';
1214

1315
const STATUS_STYLES: Record<string, string> = {
@@ -103,6 +105,7 @@ export default function QuotesPage() {
103105
const { session, isLoading: sessionLoading } = useSessionContext();
104106
const [quotes, setQuotes] = useState<Quote[]>([]);
105107
const [loading, setLoading] = useState(true);
108+
const [loadError, setLoadError] = useState(false);
106109
const [statusFilter, setStatusFilter] = useState('');
107110

108111
useEffect(() => {
@@ -113,8 +116,9 @@ export default function QuotesPage() {
113116
try {
114117
const data = await quotesApi.getAll();
115118
setQuotes(data.quotes || []);
116-
} catch {
117-
toast.error('Failed to load quotes');
119+
} catch (err) {
120+
setLoadError(true);
121+
toast.error(apiErrorMessage(err, 'Failed to load quotes'));
118122
} finally {
119123
setLoading(false);
120124
}
@@ -127,12 +131,17 @@ export default function QuotesPage() {
127131
await quotesApi.updateStatus(id, status);
128132
await load();
129133
toast.success(`Quote marked as ${status}`);
130-
} catch {
131-
toast.error('Update failed');
134+
} catch (err) {
135+
toast.error(apiErrorMessage(err, 'Update failed'));
132136
}
133137
};
134138

135139
if (sessionLoading || !session) return <LoadingSpinner fullPage />;
140+
if (loadError && quotes.length === 0) return (
141+
<Layout title="Quotes">
142+
<PageError message="Couldn't load quotes" onRetry={() => { setLoadError(false); load(); }} />
143+
</Layout>
144+
);
136145

137146
const filtered = statusFilter ? quotes.filter(q => q.status === statusFilter) : quotes;
138147
const totalPipeline = quotes.filter(q => q.status !== 'rejected').reduce((s, q) => s + q.total, 0);

frontend/pages/relationships/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { formatDistanceToNow } from 'date-fns';
77
import toast from 'react-hot-toast';
88
import Layout from '@/components/Layout';
99
import LoadingSpinner from '@/components/LoadingSpinner';
10+
import PageError from '@/components/PageError';
1011
import { relationshipsApi } from '@/lib/api';
12+
import { apiErrorMessage } from '@/lib/apiError';
1113
import type { RelationshipContact } from '@/lib/types';
1214

1315
const HEALTH_COLORS = {
@@ -35,6 +37,7 @@ export default function RelationshipsPage() {
3537
const { session, isLoading: sessionLoading } = useSessionContext();
3638
const [contacts, setContacts] = useState<RelationshipContact[]>([]);
3739
const [loading, setLoading] = useState(true);
40+
const [loadError, setLoadError] = useState(false);
3841
const [refreshing, setRefreshing] = useState(false);
3942
const [filter, setFilter] = useState<'all' | 'at_risk' | 'alert'>('all');
4043

@@ -46,8 +49,9 @@ export default function RelationshipsPage() {
4649
try {
4750
const data = await relationshipsApi.getAll();
4851
setContacts(data.contacts || []);
49-
} catch {
50-
toast.error('Failed to load relationships');
52+
} catch (err) {
53+
setLoadError(true);
54+
toast.error(apiErrorMessage(err, 'Failed to load relationships'));
5155
} finally {
5256
setLoading(false);
5357
}
@@ -62,6 +66,11 @@ export default function RelationshipsPage() {
6266
};
6367

6468
if (sessionLoading || !session) return <LoadingSpinner fullPage />;
69+
if (loadError && contacts.length === 0) return (
70+
<Layout title="Relationships">
71+
<PageError message="Couldn't load relationships" onRetry={() => { setLoadError(false); load(); }} />
72+
</Layout>
73+
);
6574

6675
const filtered = contacts.filter(c => {
6776
if (filter === 'at_risk') return c.health_label === 'at_risk';

frontend/pages/revenue/index.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { DollarSign, AlertTriangle, RefreshCw, Loader2, Check, X, Zap } from 'lu
66
import toast from 'react-hot-toast';
77
import Layout from '@/components/Layout';
88
import LoadingSpinner from '@/components/LoadingSpinner';
9+
import PageError from '@/components/PageError';
910
import { revenueApi } from '@/lib/api';
11+
import { apiErrorMessage } from '@/lib/apiError';
1012
import type { RevenueSummary, RevenueSignal } from '@/lib/types';
1113

1214
const TYPE_COLORS: Record<string, string> = {
@@ -35,6 +37,7 @@ export default function RevenuePage() {
3537
const { session, isLoading: sessionLoading } = useSessionContext();
3638
const [summary, setSummary] = useState<RevenueSummary | null>(null);
3739
const [loading, setLoading] = useState(true);
40+
const [loadError, setLoadError] = useState(false);
3841
const [scanning, setScanning] = useState(false);
3942
const [updatingId, setUpdatingId] = useState<string | null>(null);
4043

@@ -46,8 +49,9 @@ export default function RevenuePage() {
4649
try {
4750
const data = await revenueApi.getSummary();
4851
setSummary(data);
49-
} catch {
50-
toast.error('Failed to load revenue data');
52+
} catch (err) {
53+
setLoadError(true);
54+
toast.error(apiErrorMessage(err, 'Failed to load revenue data'));
5155
} finally {
5256
setLoading(false);
5357
}
@@ -61,8 +65,8 @@ export default function RevenuePage() {
6165
const result = await revenueApi.scan();
6266
toast.success(`Found ${result.signals_found} new signals`);
6367
await load();
64-
} catch {
65-
toast.error('Scan failed');
68+
} catch (err) {
69+
toast.error(apiErrorMessage(err, 'Scan failed'));
6670
} finally {
6771
setScanning(false);
6872
}
@@ -73,14 +77,19 @@ export default function RevenuePage() {
7377
try {
7478
await revenueApi.updateSignal(signal.id, status);
7579
await load();
76-
} catch {
77-
toast.error('Update failed');
80+
} catch (err) {
81+
toast.error(apiErrorMessage(err, 'Update failed'));
7882
} finally {
7983
setUpdatingId(null);
8084
}
8185
};
8286

8387
if (sessionLoading || !session) return <LoadingSpinner fullPage />;
88+
if (loadError && !summary) return (
89+
<Layout title="Revenue Signals">
90+
<PageError message="Couldn't load revenue data" onRetry={() => { setLoadError(false); load(); }} />
91+
</Layout>
92+
);
8493

8594
return (
8695
<>

0 commit comments

Comments
 (0)