feat: add toast notifications for error handling and success messages across various components
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
backend/celerybeat-schedule
|
||||||
|
backend/data/dociva.db
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user