feat: add toast notifications for error handling and success messages across various components

This commit is contained in:
Your Name
2026-03-22 16:48:07 +02:00
parent 70d7f09110
commit ce610f5c6e
11 changed files with 80 additions and 19 deletions

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ htmlcov/
.coverage .coverage
coverage/ coverage/
backend/celerybeat-schedule
backend/data/dociva.db

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import { Toaster } from 'sonner';
import Header from '@/components/layout/Header'; import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer'; import Footer from '@/components/layout/Footer';
import CookieConsent from '@/components/layout/CookieConsent'; import CookieConsent from '@/components/layout/CookieConsent';
@@ -86,6 +87,7 @@ export default function App() {
useDirection(); useDirection();
const location = useLocation(); const location = useLocation();
const refreshUser = useAuthStore((state) => state.refreshUser); const refreshUser = useAuthStore((state) => state.refreshUser);
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
useEffect(() => { useEffect(() => {
initAnalytics(); initAnalytics();
@@ -196,6 +198,16 @@ export default function App() {
<Footer /> <Footer />
<SiteAssistant /> <SiteAssistant />
<CookieConsent /> <CookieConsent />
<Toaster
position={isRTL ? 'top-left' : 'top-right'}
dir={isRTL ? 'rtl' : 'ltr'}
richColors
closeButton
duration={4000}
toastOptions={{
className: 'text-sm',
}}
/>
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useDropzone, type Accept, type FileRejection } from 'react-dropzone'; import { useDropzone, type Accept, type FileRejection } from 'react-dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Upload, File, X } from 'lucide-react'; import { Upload, File, X } from 'lucide-react';
import { toast } from 'sonner';
import { formatFileSize } from '@/utils/textTools'; import { formatFileSize } from '@/utils/textTools';
interface FileUploaderProps { interface FileUploaderProps {
@@ -53,7 +54,9 @@ export default function FileUploader({
(rejectedFiles: FileRejection[]) => { (rejectedFiles: FileRejection[]) => {
const code = rejectedFiles[0]?.errors[0]?.code; const code = rejectedFiles[0]?.errors[0]?.code;
if (code === 'file-too-large') { if (code === 'file-too-large') {
setSizeError(t('errors.fileTooLarge', { size: maxSizeMB })); const msg = t('common.errors.fileTooLarge', { size: maxSizeMB });
setSizeError(msg);
toast.error(msg);
} }
}, },
[maxSizeMB, t] [maxSizeMB, t]

View File

@@ -1,4 +1,6 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { uploadFile, type TaskResponse } from '@/services/api'; import { uploadFile, type TaskResponse } from '@/services/api';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
@@ -33,6 +35,7 @@ export function useFileUpload({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const extraDataRef = useRef(extraData); const extraDataRef = useRef(extraData);
extraDataRef.current = extraData; extraDataRef.current = extraData;
const { t } = useTranslation();
const selectFile = useCallback( const selectFile = useCallback(
(selectedFile: File) => { (selectedFile: File) => {
@@ -45,7 +48,9 @@ export function useFileUpload({
// Client-side size check // Client-side size check
const maxBytes = maxSizeMB * 1024 * 1024; const maxBytes = maxSizeMB * 1024 * 1024;
if (selectedFile.size > maxBytes) { if (selectedFile.size > maxBytes) {
setError(`File too large. Maximum size is ${maxSizeMB}MB.`); const msg = t('common.errors.fileTooLarge', { size: maxSizeMB });
setError(msg);
toast.error(msg);
trackEvent('upload_rejected_client', { trackEvent('upload_rejected_client', {
endpoint, endpoint,
reason: 'size_limit', reason: 'size_limit',
@@ -60,7 +65,9 @@ export function useFileUpload({
if (acceptedTypes && acceptedTypes.length > 0) { if (acceptedTypes && acceptedTypes.length > 0) {
const selectedExt = selectedFile.name.split('.').pop()?.toLowerCase(); const selectedExt = selectedFile.name.split('.').pop()?.toLowerCase();
if (!selectedExt || !acceptedTypes.includes(selectedExt)) { if (!selectedExt || !acceptedTypes.includes(selectedExt)) {
setError(`Invalid file type. Accepted: ${acceptedTypes.join(', ')}`); const msg = t('common.errors.invalidFileType', { types: acceptedTypes.join(', ') });
setError(msg);
toast.error(msg);
trackEvent('upload_rejected_client', { trackEvent('upload_rejected_client', {
endpoint, endpoint,
reason: 'invalid_type', reason: 'invalid_type',
@@ -82,7 +89,9 @@ export function useFileUpload({
const startUpload = useCallback(async (): Promise<string | null> => { const startUpload = useCallback(async (): Promise<string | null> => {
if (!file) { if (!file) {
setError('No file selected.'); const msg = t('common.errors.noFileSelected');
setError(msg);
toast.error(msg);
return null; return null;
} }
@@ -104,8 +113,9 @@ export function useFileUpload({
trackEvent('upload_accepted', { endpoint }); trackEvent('upload_accepted', { endpoint });
return response.task_id; return response.task_id;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed.'; const message = err instanceof Error ? err.message : t('common.errors.uploadFailed');
setError(message); setError(message);
toast.error(message);
setIsUploading(false); setIsUploading(false);
trackEvent('upload_failed', { endpoint }); trackEvent('upload_failed', { endpoint });
return null; return null;

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import i18n from '@/i18n';
import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api'; import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
@@ -55,25 +57,31 @@ export function useTaskPolling({
if (taskResult?.status === 'completed') { if (taskResult?.status === 'completed') {
setResult(taskResult); setResult(taskResult);
toast.success(i18n.t('result.conversionComplete'), {
description: i18n.t('result.downloadReady'),
});
trackEvent('task_completed', { task_id: taskId }); trackEvent('task_completed', { task_id: taskId });
onComplete?.(taskResult); onComplete?.(taskResult);
} else { } else {
const errMsg = taskResult?.error || 'Processing failed.'; const errMsg = taskResult?.error || i18n.t('common.errors.processingFailed');
setError(errMsg); setError(errMsg);
toast.error(errMsg);
trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' }); trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' });
onError?.(errMsg); onError?.(errMsg);
} }
} else if (taskStatus.state === 'FAILURE') { } else if (taskStatus.state === 'FAILURE') {
stopPolling(); stopPolling();
const errMsg = taskStatus.error || 'Task failed.'; const errMsg = taskStatus.error || i18n.t('common.errors.processingFailed');
setError(errMsg); setError(errMsg);
toast.error(errMsg);
trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' }); trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' });
onError?.(errMsg); onError?.(errMsg);
} }
} catch (err) { } catch (err) {
stopPolling(); stopPolling();
const errMsg = err instanceof Error ? err.message : 'Polling failed.'; const errMsg = err instanceof Error ? err.message : i18n.t('common.errors.networkError');
setError(errMsg); setError(errMsg);
toast.error(errMsg);
trackEvent('task_failed', { task_id: taskId, reason: 'polling_error' }); trackEvent('task_failed', { task_id: taskId, reason: 'polling_error' });
onError?.(errMsg); onError?.(errMsg);
} }

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { import {
AlertTriangle, AlertTriangle,
BarChart3, BarChart3,
@@ -222,7 +223,9 @@ export default function AccountPage() {
setSubmitError(null); setSubmitError(null);
if (mode === 'register' && password !== confirmPassword) { if (mode === 'register' && password !== confirmPassword) {
setSubmitError(t('account.passwordMismatch')); const msg = t('account.passwordMismatch');
setSubmitError(msg);
toast.error(msg);
return; return;
} }
@@ -236,7 +239,9 @@ export default function AccountPage() {
setPassword(''); setPassword('');
setConfirmPassword(''); setConfirmPassword('');
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : t('account.loadFailed')); const msg = error instanceof Error ? error.message : t('account.loadFailed');
setSubmitError(msg);
toast.error(msg);
} }
}; };
@@ -248,7 +253,9 @@ export default function AccountPage() {
setUsage(null); setUsage(null);
setApiKeys([]); setApiKeys([]);
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : t('account.loadFailed')); const msg = error instanceof Error ? error.message : t('account.loadFailed');
setSubmitError(msg);
toast.error(msg);
} }
}; };
@@ -264,7 +271,9 @@ export default function AccountPage() {
setRevealedKey(key.raw_key ?? null); setRevealedKey(key.raw_key ?? null);
setNewKeyName(''); setNewKeyName('');
} catch (error) { } catch (error) {
setNewKeyError(error instanceof Error ? error.message : t('account.loadFailed')); const msg = error instanceof Error ? error.message : t('account.loadFailed');
setNewKeyError(msg);
toast.error(msg);
} finally { } finally {
setNewKeyCreating(false); setNewKeyCreating(false);
} }

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import { toast } from 'sonner';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { getApiClient } from '@/services/api'; import { getApiClient } from '@/services/api';
@@ -40,14 +41,18 @@ export default function ContactPage() {
message: data.get('message'), message: data.get('message'),
}); });
setSubmitted(true); setSubmitted(true);
toast.success(t('pages.contact.successMessage'));
} catch (err: unknown) { } catch (err: unknown) {
let errMsg = '';
if (isAxiosError(err) && err.response?.data?.error) { if (isAxiosError(err) && err.response?.data?.error) {
setError(err.response.data.error); errMsg = err.response.data.error;
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message); errMsg = err.message;
} else { } else {
setError(String(err)); errMsg = String(err);
} }
setError(errMsg);
toast.error(errMsg);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { Mail } from 'lucide-react'; import { Mail } from 'lucide-react';
import { toast } from 'sonner';
import { getApiClient } from '../services/api'; import { getApiClient } from '../services/api';
const api = getApiClient(); const api = getApiClient();
@@ -21,8 +22,11 @@ export default function ForgotPasswordPage() {
try { try {
await api.post('/auth/forgot-password', { email }); await api.post('/auth/forgot-password', { email });
setSubmitted(true); setSubmitted(true);
toast.success(t('auth.forgotPassword.sent'));
} catch { } catch {
setError(t('auth.forgotPassword.error')); const errMsg = t('auth.forgotPassword.error');
setError(errMsg);
toast.error(errMsg);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { KeyRound } from 'lucide-react'; import { KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import { getApiClient } from '../services/api'; import { getApiClient } from '../services/api';
const api = getApiClient(); const api = getApiClient();
@@ -24,11 +25,15 @@ export default function ResetPasswordPage() {
setError(null); setError(null);
if (password.length < 8) { if (password.length < 8) {
setError(t('auth.resetPassword.tooShort')); const msg = t('auth.resetPassword.tooShort');
setError(msg);
toast.error(msg);
return; return;
} }
if (password !== confirm) { if (password !== confirm) {
setError(t('account.passwordMismatch')); const msg = t('account.passwordMismatch');
setError(msg);
toast.error(msg);
return; return;
} }
@@ -36,9 +41,12 @@ export default function ResetPasswordPage() {
try { try {
await api.post('/auth/reset-password', { token, password }); await api.post('/auth/reset-password', { token, password });
setSuccess(true); setSuccess(true);
toast.success(t('auth.resetPassword.success'));
setTimeout(() => navigate('/account'), 3000); setTimeout(() => navigate('/account'), 3000);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t('auth.resetPassword.error')); const errMsg = err instanceof Error ? err.message : t('auth.resetPassword.error');
setError(errMsg);
toast.error(errMsg);
} finally { } finally {
setLoading(false); setLoading(false);
} }