diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx index d59296ad9..a11742cb6 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx @@ -19,7 +19,7 @@ import { import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { useGetCertificationsQuery, useCreateCertificationMutation, @@ -75,6 +75,7 @@ function CertificationForm({ export function CertificationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data, isLoading, isError } = useGetCertificationsQuery(); const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation(); const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation(); @@ -104,8 +105,8 @@ export function CertificationPage() { notify.success(t('certification.created')); } resetForm(); - } catch { - notify.error(t('certification.error')); + } catch (e) { + handleError(e); } }; @@ -116,8 +117,8 @@ export function CertificationPage() { notify.success(t('certification.deleted')); closeDelete(); setDeleteTarget(null); - } catch { - notify.error(t('certification.error')); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx index 17902ca67..08361d711 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx @@ -21,7 +21,7 @@ import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { LocationPage } from '../../location/pages/LocationPage'; import { CertificationPage } from '../../certification/pages/CertificationPage'; import { @@ -131,6 +131,7 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa function ProfessionTab() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data: deptRes } = useGetOrganizationsQuery(); const { data: profRes, isLoading, isError } = useGetProfessionsQuery(); const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation(); @@ -172,10 +173,10 @@ function ProfessionTab() { notify.success(t('configuration.deleted')); closeDelete(); setDeleteTarget(null); - } catch { - notify.error(t('configuration.error')); + } catch (e) { + handleError(e); } - }, [deleteTarget, deleteProfession, closeDelete, t]); + }, [deleteTarget, deleteProfession, closeDelete, handleError]); const handleProfSubmit = useCallback(async (values: ProfFormValues) => { const name = { en: values.nameEn, am: values.nameAm }; @@ -199,10 +200,10 @@ function ProfessionTab() { notify.success(t('configuration.created')); } resetProfForm(); - } catch { - notify.error(t('configuration.error')); + } catch (e) { + handleError(e); } - }, [editingProf, createProfession, updateProfession, resetProfForm, t]); + }, [editingProf, createProfession, updateProfession, resetProfForm, handleError]); const getDeptName = useCallback((deptId: string) => { const dept = departments.find((d) => d.id === deptId); diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 4e7306f5a..c2616b164 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -39,7 +39,7 @@ import { IconCheck, IconX, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api'; import { useGetQuestionsQuery } from '../../question/api/question-api'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; @@ -69,6 +69,7 @@ function InfoRow({ label, value }: { label: string; value: string }) { export function ExamDetailPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const printRef = useRef(null); @@ -154,8 +155,8 @@ export function ExamDetailPage() { await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap(); notify.success('Questions assigned'); closeAssign(); - } catch { - notify.error('Failed to assign questions'); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx index ab9404fe4..a31195bfd 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx @@ -25,7 +25,7 @@ import { import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; import { useGetExamsQuery, @@ -149,6 +149,7 @@ function ExamForm({ export function ExamPage() { const navigate = useNavigate(); const { t, i18n } = useTranslation(); + const { handleError } = useErrorHandler(); const locale = i18n.language as 'en' | 'am'; const { data: certRes } = useGetCertificationsQuery(); const { data, isLoading, isError } = useGetExamsQuery(); @@ -195,8 +196,8 @@ export function ExamPage() { notify.success(t('exam.created')); } resetForm(); - } catch { - notify.error(t('exam.error')); + } catch (e) { + handleError(e); } }; @@ -207,8 +208,8 @@ export function ExamPage() { notify.success(t('exam.deleted')); closeDelete(); setDeleteTarget(null); - } catch { - notify.error(t('exam.error')); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/item/components/ItemTable.tsx b/apps/backoffice/src/app/features/item/components/ItemTable.tsx index ca7fde9b2..bf3bd95b0 100644 --- a/apps/backoffice/src/app/features/item/components/ItemTable.tsx +++ b/apps/backoffice/src/app/features/item/components/ItemTable.tsx @@ -1,7 +1,7 @@ import { Table, Badge, ActionIcon, Text } from '@mantine/core'; import { IconTrash } from '@tabler/icons-react'; import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; const STATUS_COLORS: Record = { DRAFT: 'gray', @@ -12,13 +12,14 @@ const STATUS_COLORS: Record = { export function ItemTable() { const { data, isLoading } = useGetItemsQuery({}); const [deleteItem] = useDeleteItemMutation(); + const { handleError } = useErrorHandler(); const handleDelete = async (id: string) => { try { await deleteItem(id).unwrap(); notify.success('Item deleted'); - } catch { - notify.error('Failed to delete item'); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx index 8db7faa67..84f05ab29 100644 --- a/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx @@ -22,7 +22,7 @@ import { useUpdateLocationTypeMutation, useDeleteLocationTypeMutation, } from '../api/location-api'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; interface LocationTypeFormValues { code: string; @@ -34,6 +34,7 @@ interface LocationTypeFormValues { export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data: locationTypes, isLoading } = useGetLocationTypesQuery(); const [createType] = useCreateLocationTypeMutation(); const [updateType] = useUpdateLocationTypeMutation(); @@ -84,8 +85,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos try { await deleteType(id).unwrap(); notify.success(t('location.typeDeleted')); - } catch { - notify.error(t('location.deleteError')); + } catch (e) { + handleError(e); } }; @@ -99,8 +100,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos notify.success(t('location.typeCreated')); } resetForm(); - } catch { - notify.error(t('location.typeError')); + } catch (e) { + handleError(e); } }); diff --git a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx index e8b4f333b..6e0fec706 100644 --- a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx +++ b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx @@ -17,7 +17,7 @@ import { import { useDisclosure } from '@mantine/hooks'; import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { LocationTree } from '../components/LocationTree'; import { LocationDetail } from '../components/LocationDetail'; import { LocationForm } from '../components/LocationForm'; @@ -33,6 +33,7 @@ import type { Location } from '../types/location'; export function LocationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery(); const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation(); const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation(); @@ -82,11 +83,11 @@ export function LocationPage() { closeFormModal(); setEditingLocation(null); setParentLocation(null); - } catch { - notify.error(t('location.error')); + } catch (e) { + handleError(e); } }, - [editingLocation, createLocation, updateLocation, closeFormModal, t], + [editingLocation, createLocation, updateLocation, closeFormModal, handleError], ); const handleDeleteConfirm = useCallback(async () => { @@ -96,10 +97,10 @@ export function LocationPage() { notify.success(t('location.deleted')); setSelectedLocation(null); closeDeleteModal(); - } catch { - notify.error(t('location.deleteError')); + } catch (e) { + handleError(e); } - }, [selectedLocation, deleteLocation, closeDeleteModal, t]); + }, [selectedLocation, deleteLocation, closeDeleteModal, handleError]); if (typesLoading) { return ( diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index e3f4ef131..63435484b 100644 --- a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { notify, PageHeader } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { setUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; @@ -77,6 +77,7 @@ export function ProfilePage() { const user = useAppSelector((state) => state.auth.user); const { colorScheme, setColorScheme } = useMantineColorScheme(); const layoutMode = useAppSelector((state) => state.preferences.layoutMode); + const { handleError } = useErrorHandler(); const [updateTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); @@ -156,8 +157,8 @@ export function ProfilePage() { dispatch(setUser(me)); notify.success(t('profile.profileUpdated')); - } catch { - notify.error(t('profile.updateFailed')); + } catch (e) { + handleError(e); } finally { setIsSavingProfile(false); } @@ -208,8 +209,8 @@ export function ProfilePage() { notify.success(t('profile.passwordChanged')); resetPassword(); - } catch { - notify.error(t('profile.passwordFailed')); + } catch (e) { + handleError(e); } finally { setIsSavingPassword(false); } diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx index b4ea5de62..8f1ec0486 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx @@ -20,7 +20,7 @@ import { import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; import { useGetQuestionsQuery, @@ -102,6 +102,7 @@ function QuestionForm({ export function QuestionPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data: certRes } = useGetCertificationsQuery(); const { data, isLoading, isError } = useGetQuestionsQuery(); const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation(); @@ -140,8 +141,8 @@ export function QuestionPage() { notify.success(t('question.created')); } resetForm(); - } catch { - notify.error(t('question.error')); + } catch (e) { + handleError(e); } }; @@ -152,8 +153,8 @@ export function QuestionPage() { notify.success(t('question.deleted')); closeDelete(); setDeleteTarget(null); - } catch { - notify.error(t('question.error')); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx index 9ecff4a8f..34bed6acb 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx @@ -17,7 +17,7 @@ import { Alert, } from '@mantine/core'; import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { useApiQuery } from '@ema-platform/api'; import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api'; import type { Exam } from '../../exam/types/exam'; @@ -42,6 +42,7 @@ export function RecordResultModal({ }) { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const [seafarerSearch, setSeafarerSearch] = useState(''); const [selectedSeafarerId, setSelectedSeafarerId] = useState(null); const [scores, setScores] = useState>({}); @@ -106,8 +107,8 @@ export function RecordResultModal({ setRemark(''); setSeafarerSearch(''); onClose(); - } catch { - notify.error(t('result.recordModal.saveError')); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx index 58445b7fa..5094a148f 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx @@ -35,7 +35,7 @@ import { IconChartBar, IconSearch, } from '@tabler/icons-react'; -import { notify, BilingualInput } from '@ema-platform/ui'; +import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui'; import type { BilingualValue } from '@ema-platform/ui'; import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api'; import { useGetExamsQuery } from '../../exam/api/exam-api'; @@ -95,6 +95,7 @@ function InfoRow({ label, value }: { label: string; value: string }) { export function ResultPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const { handleError } = useErrorHandler(); const { data: examRes } = useGetExamsQuery(); const { data, isLoading, isError } = useGetResultsQuery(); const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery(); @@ -180,8 +181,8 @@ export function ResultPage() { }).unwrap(); notify.success(t('result.updated')); closeDetail(); - } catch { - notify.error(t('result.error')); + } catch (e) { + handleError(e); } finally { setDetailSaving(false); } @@ -201,8 +202,8 @@ export function ResultPage() { notify.success(t('result.deleted')); closeDelete(); setDeleteTarget(null); - } catch { - notify.error(t('result.error')); + } catch (e) { + handleError(e); } }; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 7f9a4efb4..b1081f2e2 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -8,6 +8,17 @@ export const am: Translations = { tagline: 'የቁጥጥር ማዕከል', }, + msg: { + genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', + serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', + validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።', + authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።', + permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።', + notFoundError: 'የተጠየቀው ንጥል አልተገኘም።', + fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።', + networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።', + }, + language: { label: 'ቋንቋ', en: 'English', diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 762217a3c..0cffc47f8 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -6,6 +6,17 @@ export const en = { tagline: 'Control Center', }, + msg: { + genericError: 'Something went wrong. Please try again.', + serverError: 'Server error. Please try again later.', + validationError: 'Please check your input and try again.', + authError: 'Your session has expired. Please sign in again.', + permissionError: "You don't have permission to perform this action.", + notFoundError: 'The requested item was not found.', + fileTooLarge: 'The file is too large to upload.', + networkError: 'Network error. Check your connection and try again.', + }, + language: { label: 'Language', en: 'English', diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index b072a9ecd..dc95636f4 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -19,6 +19,7 @@ import { rem, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; +import { useErrorHandler } from '@ema-platform/ui'; import { IconArrowRight, IconBook2, @@ -98,6 +99,7 @@ export function CertificatesPage() { const [previewUrl, setPreviewUrl] = useState(null); const [previewTitle, setPreviewTitle] = useState(''); const [loading, setLoading] = useState(false); + const { handleError } = useErrorHandler(); const openPreview = async (profileId: string, title: string) => { setLoading(true); @@ -107,11 +109,7 @@ export function CertificatesPage() { setPreviewTitle(title); setPreviewUrl(url); } catch (err) { - notifications.show({ - color: 'red', - title: 'Error', - message: err instanceof Error ? err.message : 'Could not generate certificate', - }); + handleError(err); } finally { setLoading(false); } @@ -127,11 +125,7 @@ export function CertificatesPage() { message: 'Certificate PDF downloaded successfully', }); } catch (err) { - notifications.show({ - color: 'red', - title: 'Error', - message: err instanceof Error ? err.message : 'Could not download certificate', - }); + handleError(err); } }; diff --git a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx index f89f8a878..24e94ce70 100644 --- a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx +++ b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx @@ -23,7 +23,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from 'react-router-dom'; import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { @@ -122,6 +122,7 @@ export function ProfileSetupPage() { const [addressTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>(); + const { handleError } = useErrorHandler(); const fetched = useRef(false); useEffect(() => { @@ -265,8 +266,8 @@ export function ProfileSetupPage() { notify.success('Profile setup complete!'); navigate('/dashboard'); - } catch { - notify.error('Failed to save profile. Please try again.'); + } catch (e) { + handleError(e); } finally { setSubmitting(false); } diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 97ba640ce..35a945e89 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -43,7 +43,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { notify, PageHeader } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth'; import type { CurrentProfile } from '@ema-platform/auth'; @@ -86,6 +86,7 @@ export function ProfilePage() { const user = useAppSelector((state) => state.auth.user); const currentProfile = useAppSelector((state) => state.auth.currentProfile); const { colorScheme, setColorScheme } = useMantineColorScheme(); + const { handleError } = useErrorHandler(); const [updateTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); @@ -255,8 +256,8 @@ export function ProfilePage() { dispatch(setUser(me)); notify.success(t('profile.profileUpdated')); - } catch { - notify.error(t('profile.updateFailed')); + } catch (e) { + handleError(e); } finally { setIsSavingProfile(false); } @@ -286,8 +287,8 @@ export function ProfilePage() { }).unwrap(); notify.success('Profile updated'); - } catch { - notify.error('Failed to update profile'); + } catch (e) { + handleError(e); } finally { setIsSavingMaritime(false); } @@ -320,8 +321,8 @@ export function ProfilePage() { }).unwrap(); notify.success('Address updated'); - } catch { - notify.error('Failed to update address'); + } catch (e) { + handleError(e); } finally { setIsSavingAddress(false); } @@ -366,8 +367,8 @@ export function ProfilePage() { notify.success(t('profile.passwordChanged')); resetPassword(); - } catch { - notify.error(t('profile.passwordFailed')); + } catch (e) { + handleError(e); } finally { setIsSavingPassword(false); } diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx index f1e7f00f6..61ea55060 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx @@ -45,7 +45,7 @@ import { IconX, } from '@tabler/icons-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import type { Seafarer } from './SeafarerRegistryPage'; // --------------------------------------------------------------------------- @@ -569,12 +569,13 @@ export function SeafarerProfilePage() { const [medicalModal, medicalModalHandlers] = useDisclosure(false); const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false); const [certModal, certModalHandlers] = useDisclosure(false); + const { handleError } = useErrorHandler(); useEffect(() => { if (!id) return; fetchSeafarerProfile(id) .then(setProfile) - .catch(() => notify.error('Failed to load seafarer profile.')) + .catch((e) => handleError(e)) .finally(() => setLoading(false)); }, [id]); @@ -584,8 +585,8 @@ export function SeafarerProfilePage() { await updateSeafarerStatus(profile.id, newStatus); setProfile((p) => p ? { ...p, status: newStatus } : p); notify.success(`Status updated to ${newStatus}.`); - } catch { - notify.error('Failed to update status.'); + } catch (e) { + handleError(e); } }; diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx index 63cb98df8..077b23cc6 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -33,7 +33,7 @@ import { IconUser, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { notify, BilingualInput } from '@ema-platform/ui'; +import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui'; import type { BilingualValue } from '@ema-platform/ui'; import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker'; import { LocationPicker } from '../../location/components/LocationPicker'; @@ -257,6 +257,7 @@ export function SeafarerRegistrationPage() { const [active, setActive] = useState(0); const [completed, setCompleted] = useState([]); const [submitting, setSubmitting] = useState(false); + const { handleError } = useErrorHandler(); // Step 1 — Personal Information const [firstName, setFirstName] = useState({ en: '', am: '' }); @@ -312,8 +313,8 @@ export function SeafarerRegistrationPage() { }); notify.success(`Registration submitted! Reference: ${result.referenceId}`); navigate('/applications'); - } catch { - notify.error('Submission failed. Please try again.'); + } catch (e) { + handleError(e); } finally { setSubmitting(false); } diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx index cbbc6e310..a80a061c8 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx @@ -35,7 +35,7 @@ import { IconX, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; // --------------------------------------------------------------------------- // Types @@ -194,11 +194,12 @@ export function SeafarerRegistryPage() { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(null); + const { handleError } = useErrorHandler(); useEffect(() => { fetchSeafarers() .then(setSeafarers) - .catch(() => notify.error('Failed to load seafarers.')) + .catch((e) => handleError(e)) .finally(() => setLoading(false)); }, []); diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx index 61f0f08fe..1e369746c 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx @@ -32,7 +32,7 @@ import { IconUpload, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { TelebirrPayment } from '../../payment/components/TelebirrPayment'; // --------------------------------------------------------------------------- @@ -212,6 +212,7 @@ export function SeamanBookApplicationPage() { const [active, setActive] = useState(0); const [completed, setCompleted] = useState([]); const [submitting, setSubmitting] = useState(false); + const { handleError } = useErrorHandler(); // BST const [bstData, setBstData] = useState>(() => @@ -268,8 +269,8 @@ export function SeamanBookApplicationPage() { await new Promise((r) => setTimeout(r, 1400)); notify.success('Application submitted! Reference: SB-BTC-2025-001'); navigate('/seaman-book'); - } catch { - notify.error('Submission failed. Please try again.'); + } catch (e) { + handleError(e); } finally { setSubmitting(false); } diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 1903ab436..d1c65e9f8 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -7,6 +7,17 @@ export const am: Translations = { tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች', }, + msg: { + genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', + serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', + validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።', + authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።', + permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።', + notFoundError: 'የተጠየቀው ንጥል አልተገኘም።', + fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።', + networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።', + }, + language: { label: 'ቋንቋ', en: 'English', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index bdae81929..66269b250 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -5,6 +5,17 @@ export const en = { tagline: 'Maritime licensing & certification services', }, + msg: { + genericError: 'Something went wrong. Please try again.', + serverError: 'Server error. Please try again later.', + validationError: 'Please check your input and try again.', + authError: 'Your session has expired. Please sign in again.', + permissionError: "You don't have permission to perform this action.", + notFoundError: 'The requested item was not found.', + fileTooLarge: 'The file is too large to upload.', + networkError: 'Network error. Check your connection and try again.', + }, + language: { label: 'Language', en: 'English', diff --git a/libs/auth/src/lib/pages/ForgotPasswordPage.tsx b/libs/auth/src/lib/pages/ForgotPasswordPage.tsx index 98552e2b8..e8b1eec06 100644 --- a/libs/auth/src/lib/pages/ForgotPasswordPage.tsx +++ b/libs/auth/src/lib/pages/ForgotPasswordPage.tsx @@ -22,7 +22,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Link } from 'react-router-dom'; import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; import { useAuthConfig } from '../AuthConfig'; @@ -37,6 +37,7 @@ export function ForgotPasswordPage() { const [forgotTrigger, { isLoading }] = useApiMutation(); const [sentTo, setSentTo] = useState(null); const [serverError, setServerError] = useState(null); + const { handleError } = useErrorHandler(); const { register, @@ -59,11 +60,7 @@ export function ForgotPasswordPage() { try { await sendResetLink(values.email); } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } }; @@ -73,11 +70,7 @@ export function ForgotPasswordPage() { await sendResetLink(sentTo); notify.success('Reset link sent again'); } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } }; diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 9be1b978b..d9b85deaf 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -24,7 +24,7 @@ import { z } from "zod"; import { useNavigate, Link } from "react-router-dom"; import { useDispatch } from "react-redux"; import { useApiMutation } from "@ema-platform/api"; -import { notify } from "@ema-platform/ui"; +import { notify, useErrorHandler } from "@ema-platform/ui"; import { AuthShell } from "../components/AuthShell"; import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice"; import type { @@ -52,6 +52,7 @@ export function LoginPage() { const [isLoading, setIsLoading] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [serverError, setServerError] = useState(null); + const { handleError } = useErrorHandler(); const [loginTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); const [profileCheckTrigger] = useApiMutation<{ @@ -118,11 +119,7 @@ export function LoginPage() { navigate(loginRedirectPath); } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : "Something went wrong"); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } finally { localStorage.setItem("rememberMe", String(rememberMe)); setIsLoading(false); diff --git a/libs/auth/src/lib/pages/OTPVerificationPage.tsx b/libs/auth/src/lib/pages/OTPVerificationPage.tsx index 17f25762e..d7cfc7c45 100644 --- a/libs/auth/src/lib/pages/OTPVerificationPage.tsx +++ b/libs/auth/src/lib/pages/OTPVerificationPage.tsx @@ -18,7 +18,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useNavigate, useLocation } from 'react-router-dom'; import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { notify, useErrorHandler } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; import { useAuthConfig } from '../AuthConfig'; @@ -48,6 +48,7 @@ export function OTPVerificationPage() { const [resendTrigger, { isLoading: resending }] = useApiMutation(); const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS); const [serverError, setServerError] = useState(null); + const { handleError } = useErrorHandler(); const { control, @@ -75,11 +76,7 @@ export function OTPVerificationPage() { notify.success('Phone number verified successfully'); navigate(needsProfile ? '/profile-setup' : loginRedirectPath); } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } }; @@ -96,11 +93,7 @@ export function OTPVerificationPage() { setSecondsLeft(RESEND_SECONDS); setServerError(null); } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } }; diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 76a875ef2..fd7231989 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -26,7 +26,7 @@ import { z } from 'zod'; import { useNavigate, Link } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { useErrorHandler } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; import { loginSuccess, setUser } from '../store/auth.slice'; import type { AuthUser } from '../types/auth.types'; @@ -69,6 +69,7 @@ export function SignupPage() { const { appName, loginRedirectPath } = useAuthConfig(); const [agreed, setAgreed] = useState(false); const [serverError, setServerError] = useState(null); + const { handleError } = useErrorHandler(); const [signupTrigger, { isLoading: loading }] = useApiMutation<{ token: string; refreshToken: string; @@ -126,11 +127,7 @@ export function SignupPage() { }); } } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); + setServerError(handleError(err)); } }; diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 62e6bd537..d8fabdd58 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/input/BilingualInput'; export * from './lib/feedback/ConfirmModal'; export * from './lib/feedback/ApiErrorAlert'; export * from './lib/feedback/notify'; +export * from './lib/feedback/use-error-handler'; export * from './lib/layout/AppHeader'; export * from './lib/layout/AppSidebar'; export * from './lib/layout/BrandAvatar'; diff --git a/libs/ui/src/lib/feedback/use-error-handler.ts b/libs/ui/src/lib/feedback/use-error-handler.ts new file mode 100644 index 000000000..c6e7eb733 --- /dev/null +++ b/libs/ui/src/lib/feedback/use-error-handler.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { notify } from './notify'; + +// Recursive: first non-empty string in a string | array | { message | error | detail }. Never throws. +function extractMessage(value: unknown): string | null { + if (value == null) return null; + if (typeof value === 'string') return value.trim() || null; + if (Array.isArray(value)) { + const parts = value.map(extractMessage).filter(Boolean) as string[]; + return parts.length ? parts.join(', ') : null; + } + if (typeof value === 'object') { + const o = value as Record; + return extractMessage(o.message) ?? extractMessage(o.error) ?? extractMessage(o.detail) ?? null; + } + return null; +} + +// HTTP status OR RTK string status (FETCH_ERROR/TIMEOUT_ERROR/PARSING_ERROR/CUSTOM_ERROR) → i18n key. +function statusKeyFor(status: number | string | undefined): string { + if (status === 400 || status === 422) return 'msg.validationError'; + if (status === 401) return 'msg.authError'; + if (status === 403) return 'msg.permissionError'; + if (status === 404) return 'msg.notFoundError'; + if (status === 413) return 'msg.fileTooLarge'; + if (typeof status === 'number' && status >= 500) return 'msg.serverError'; + if (typeof status === 'string') return 'msg.networkError'; + return 'msg.genericError'; +} + +function logError(err: unknown): void { + if (err && typeof err === 'object' && 'status' in err) { + console.error('[API ERROR]', (err as { status?: unknown }).status, (err as { data?: unknown }).data); + return; + } + console.error('Error caught:', err); +} + +export function useErrorHandler() { + const { t } = useTranslation(); + + // Priority: backend message (FetchBaseQueryError.data) → Error.message → status/network fallback key. + const getErrorMessage = useCallback( + (err: unknown): string => { + let status: number | string | undefined; + if (err && typeof err === 'object' && ('status' in err || 'data' in err)) { + status = (err as { status?: number | string }).status; + const fromData = extractMessage((err as { data?: unknown }).data); + if (fromData) return fromData; + } + if (err instanceof Error) { + const fromError = extractMessage(err.message); + if (fromError) return fromError; + } + return t(statusKeyFor(status)); + }, + [t], + ); + + const handleError = useCallback( + (err: unknown): string => { + logError(err); + const message = getErrorMessage(err); + notify.error(message); + return message; + }, + [getErrorMessage], + ); + + return { getErrorMessage, handleError }; +}