improvement: error handeling structure changed and unified all across the app. user no longer sees inappropriate messages.

This commit is contained in:
2026-07-12 18:27:38 +03:30
parent 901d838a2c
commit fab5111aa8
45 changed files with 977 additions and 241 deletions

View File

@@ -20,7 +20,7 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
@@ -32,6 +32,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function AppointmentsPage() {
const t = useTranslations('appointments');
const tErrors = useTranslations('errors');
const tPatients = useTranslations('patients');
const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
@@ -105,7 +106,7 @@ export default function AppointmentsPage() {
if (gen !== scheduleLoadGen.current) {
return;
}
toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule')));
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
@@ -178,11 +179,7 @@ export default function AppointmentsPage() {
);
}
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: tPatients('errorSavePatient');
toast.showError(message);
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
} finally {
setSavingPatient(false);
}
@@ -248,13 +245,13 @@ export default function AppointmentsPage() {
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: activeEditingAppointment
? t('errorUpdate')
: t('errorSave');
toast.showError(message);
toast.showError(
getUserFacingError(
err,
tErrors,
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
),
);
} finally {
setSavingAppointment(false);
}
@@ -276,11 +273,7 @@ export default function AppointmentsPage() {
toast.showSuccess(t('successRemoved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: t('errorDelete');
toast.showError(message);
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
} finally {
setDeletingAppointment(false);
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
@@ -35,6 +35,7 @@ const PAGE_SIZE = 20;
export default function CasesPage() {
const t = useTranslations('cases');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
@@ -112,7 +113,7 @@ export default function CasesPage() {
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
} finally {
setLoadingList(false);
}
@@ -127,7 +128,7 @@ export default function CasesPage() {
const response = await casesApi.getOne(caseId);
setSelectedCase(response.data);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail')));
if (!options?.silent) {
setSelectedCase(null);
}
@@ -218,7 +219,7 @@ export default function CasesPage() {
setSelectedCase(response.data);
} catch (error: unknown) {
setSelectedCase(previousCase);
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingImportant(false);
}

View File

@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
import { getUserFacingError } from '@/components/shared/formatApiError';
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
@@ -42,6 +42,7 @@ type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const t = useTranslations('organizations');
const tErrors = useTranslations('errors');
const tNav = useTranslations('nav');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
@@ -49,14 +50,8 @@ export default function OrganizationsPage() {
const toast = useToast();
const formatApiMessage = useCallback(
(err: unknown): string => {
if (!err || typeof err !== 'object') return tCommon('errorGeneric');
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return tCommon('errorGeneric');
},
[tCommon],
(err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')),
[tCommon, tErrors],
);
const formatConnectionStatusLabel = useCallback(

View File

@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
@@ -23,6 +23,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function PatientsPage() {
const t = useTranslations('patients');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const toast = useToast();
@@ -67,7 +68,7 @@ export default function PatientsPage() {
setSelectedPatient(freshSelected);
}
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadPatients')));
} finally {
setLoadingPatients(false);
}
@@ -98,7 +99,7 @@ export default function PatientsPage() {
);
}
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient')));
} finally {
setSavingPatient(false);
}

View File

@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/shared/Input';
import { Toast } from '@/components/ui/shared/Toast';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
type PasswordForm = {
@@ -26,6 +27,7 @@ type PasswordForm = {
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tErrors = useTranslations('errors');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
@@ -152,8 +154,7 @@ export default function AccountSettingsPage() {
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTasks'));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
setParticipatesInTasks(false);
} finally {
setParticipationLoading(false);
@@ -180,8 +181,7 @@ export default function AccountSettingsPage() {
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
} finally {
setParticipationLoading(false);
}
@@ -214,8 +214,7 @@ export default function AccountSettingsPage() {
try {
await enableClinicParticipation(options);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
throw err;
} finally {
setParticipationLoading(false);
@@ -249,8 +248,7 @@ export default function AccountSettingsPage() {
setSuccessMessage(t('passwordChanged'));
router.replace('/login');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('passwordChangeFailed');
setError(message || t('passwordChangeFailed'));
setError(getUserFacingError(err, tErrors, t('passwordChangeFailed')));
} finally {
setIsSubmitting(false);
}

View File

@@ -35,7 +35,7 @@ import { Input } from '@/components/ui/shared/Input';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList';
import { useToast } from '@/lib/hooks/useToast';
@@ -147,6 +147,7 @@ function PermissionGrid({
export default function StaffPage() {
const router = useRouter();
const t = useTranslations('staff');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const tFeatures = useTranslations('staff.features');
const tWorkingHours = useTranslations('staff.workingHours');
@@ -229,7 +230,7 @@ export default function StaffPage() {
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorLoadStaff')));
toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff')));
} finally {
setLoading(false);
}
@@ -304,7 +305,7 @@ export default function StaffPage() {
await load();
}
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -387,7 +388,7 @@ export default function StaffPage() {
resetInviteForm();
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorSendInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite')));
} finally {
setInviteLoading(false);
}
@@ -410,7 +411,7 @@ export default function StaffPage() {
setEditWorkingHoursDays(state.days);
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours')));
toast.showError(getUserFacingError(e, tErrors, t('errorLoadWorkingHours')));
} finally {
setEditLoadingWorkingHours(false);
}
@@ -449,7 +450,7 @@ export default function StaffPage() {
setEditStep(1);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorUpdateMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember')));
} finally {
setEditLoading(false);
}
@@ -470,7 +471,7 @@ export default function StaffPage() {
setDisableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorDisableMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember')));
} finally {
setDisablingMembershipId(null);
}
@@ -487,7 +488,7 @@ export default function StaffPage() {
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorEnableMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember')));
} finally {
setEnablingMembershipId(null);
}
@@ -600,7 +601,7 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}

View File

@@ -17,7 +17,7 @@ import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
@@ -38,6 +38,7 @@ function formatPatientName(patient: { firstName: string; lastName: string }) {
export default function TasksPage() {
const t = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { currentOrganization, user, isAuthReady } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
@@ -107,7 +108,7 @@ export default function TasksPage() {
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
} finally {
setLoading(false);
}
@@ -127,7 +128,7 @@ export default function TasksPage() {
await tasksApi.updateStatus(taskId, status);
await loadTasks();
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { TodayDashboard } from '@/components/today/TodayDashboard';
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
@@ -13,6 +13,7 @@ import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
export default function TodayPage() {
const t = useTranslations('today');
const tErrors = useTranslations('errors');
const { currentOrganization } = useAuth();
const orgId = currentOrganization?.id;
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
@@ -54,7 +55,7 @@ export default function TodayPage() {
{error ? (
<TodayLoadErrorBanner
message={formatApiErrorMessage(error, t('loadError'))}
message={getUserFacingError(error, tErrors, t('loadError'))}
retryLabel={t('retryLoad')}
onRetry={() => void reload()}
isRetrying={loading && Boolean(data)}

View File

@@ -7,10 +7,12 @@ import { Link, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() {
const t = useTranslations('auth');
const tErrors = useTranslations('errors');
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
@@ -49,8 +51,7 @@ function AcceptInviteContent() {
setSuccess(t('invitationAlreadyAccepted'));
}
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorLoadInvitation'));
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
} finally {
setLoading(false);
}
@@ -86,8 +87,7 @@ function AcceptInviteContent() {
router.replace('/login');
}, 1000);
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorAcceptInvitation'));
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
} finally {
setSubmitting(false);
}

View File

@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { organizationApi } from '@/lib/api/organization';
type AcceptOrganizationInviteForm = {
@@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = {
function AcceptOrganizationInviteContent() {
const t = useTranslations('auth');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const params = useSearchParams();
@@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() {
setSuccess(t('invitationAlreadyAccepted'));
}
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorLoadInvitation'));
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
} finally {
setLoading(false);
}
@@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() {
setSuccess(t('organizationAcceptedRedirect'));
setTimeout(() => router.replace('/login'), 1000);
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorAcceptInvitation'));
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
} finally {
setSubmitting(false);
}

View File

@@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { Phone, ShieldCheck } from 'lucide-react';
import { authApi } from '@/lib/api/auth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
import { Button } from '@/components/ui/shared/Button';
@@ -29,6 +30,7 @@ export default function ForgotPasswordPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const router = useRouter();
const { refreshSession } = useAuth();
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
@@ -76,8 +78,7 @@ export default function ForgotPasswordPage() {
setSentMobile(mobile);
setStep('code');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('codeSendFailed');
setError(message || t('codeSendFailed'));
setError(getUserFacingError(err, tErrors, t('codeSendFailed')));
} finally {
setIsSending(false);
}
@@ -116,8 +117,7 @@ export default function ForgotPasswordPage() {
await refreshSession();
router.push('/settings/account?reset=1');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('verifyFailed');
setError(message || t('verifyFailed'));
setError(getUserFacingError(err, tErrors, t('verifyFailed')));
} finally {
setIsVerifying(false);
}

View File

@@ -8,6 +8,7 @@ import * as z from 'zod';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Mail, Lock } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { getRememberedEmail } from '@/lib/auth/rememberMe';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
@@ -25,6 +26,7 @@ export default function LoginPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
@@ -67,8 +69,7 @@ export default function LoginPage() {
setError(null);
await login(data.email, data.password, data.rememberMe);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('invalidCredentials');
setError(message || t('invalidCredentials'));
setError(getUserFacingError(err, tErrors, t('invalidCredentials')));
}
};

View File

@@ -7,6 +7,7 @@ import * as z from 'zod';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Mail, Lock, User, Phone } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
@@ -36,6 +37,7 @@ export default function RegisterPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const { registerTrial, isLoading } = useAuth();
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
@@ -110,8 +112,7 @@ export default function RegisterPage() {
data.organizationType,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('registrationFailed');
setError(message || t('registrationFailed'));
setError(getUserFacingError(err, tErrors, t('registrationFailed')));
}
};