From 53b43f2cdb4240379ac7e8e641419452561ac140 Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 12 Jul 2026 21:30:28 +0330
Subject: [PATCH] improvement: updated toasts. no invisible toast anymore.
---
.cursor/rules/frontend-components.mdc | 6 +
.../src/app/[locale]/(dashboard)/layout.tsx | 7 +-
.../ui/appointments/AppointmentsPage.tsx | 3 -
frontend/src/components/ui/lab/CasesPage.tsx | 2 -
frontend/src/components/ui/lab/TasksPage.tsx | 2 -
.../ConnectionCaseHistoryContent.tsx | 3 -
.../organizations/InvitationHistoryDialog.tsx | 10 +-
.../ui/organizations/OrganizationsPage.tsx | 4 -
.../components/ui/patient/PatientsPage.tsx | 3 -
frontend/src/components/ui/shared/Toast.tsx | 12 +-
.../components/ui/shared/ToastProvider.tsx | 213 ++++++++++++++++++
.../src/components/ui/staff/StaffPage.tsx | 3 -
.../ui/treatment/TreatmentWorkspace.tsx | 3 -
frontend/src/lib/hooks/useToast.ts | 105 +--------
14 files changed, 236 insertions(+), 140 deletions(-)
create mode 100644 frontend/src/components/ui/shared/ToastProvider.tsx
diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc
index aecfc52..2714891 100644
--- a/.cursor/rules/frontend-components.mdc
+++ b/.cursor/rules/frontend-components.mdc
@@ -40,6 +40,12 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork
- API clients: `lib/api/`.
- Catch blocks: `getUserFacingError(err, tErrors, fallback)` from `components/shared/formatApiError.ts`.
+## Toasts
+
+- Use `useToast()` for transient page feedback — rendered globally by `ToastProvider` in dashboard layout (fixed bottom, above dialogs).
+- Do **not** add `` inside pages or dialogs.
+- Form/dialog validation: inline error text near the field or submit button, not a toast.
+
## When adding UI
1. Check `components/ui/shared/` for an existing primitive.
diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx
index 4d7ffbe..b6d836b 100644
--- a/frontend/src/app/[locale]/(dashboard)/layout.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
+import { ToastProvider } from '@/components/ui/shared/ToastProvider';
import {
canAccessDashboardRoute,
firstAccessibleDashboardPath,
@@ -70,7 +71,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
return (
-
+
+
);
}
diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx
index 6dc3a7e..e138f53 100644
--- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx
+++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx
@@ -17,7 +17,6 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
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 { getUserFacingError } from '@/components/shared/formatApiError';
@@ -286,8 +285,6 @@ export function AppointmentsPage() {
{t('subtitle')}
-
-
);
}
diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx
index d154d18..005f8f3 100644
--- a/frontend/src/components/ui/lab/TasksPage.tsx
+++ b/frontend/src/components/ui/lab/TasksPage.tsx
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
-import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
@@ -397,7 +396,6 @@ export function TasksPage() {
)}
-
);
}
diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
index 16f4832..af0de87 100644
--- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
+++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx
@@ -12,7 +12,6 @@ import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentType
import { Button } from '@/components/ui/shared/Button';
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
import { SearchBar } from '@/components/ui/shared/SearchBar';
-import { ToastStack } from '@/components/ui/shared/Toast';
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
@@ -210,8 +209,6 @@ export function ConnectionCaseHistoryContent({
-
-
void;
- /** Same page-level toasts, rendered at top of dialog while it is open. */
- toastMessages?: ToastMessages;
};
export function InvitationHistoryDialog({
@@ -40,7 +37,6 @@ export function InvitationHistoryDialog({
copiedId,
copyingInvitationId,
onCopy,
- toastMessages,
}: InvitationHistoryDialogProps) {
const t = useTranslations('organizations');
@@ -72,8 +68,6 @@ export function InvitationHistoryDialog({
- {toastMessages && }
-
{loading ? (
{t('loadingHistory')}
) : items.length === 0 ? (
diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx
index 6272ccc..3ddbfb2 100644
--- a/frontend/src/components/ui/organizations/OrganizationsPage.tsx
+++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx
@@ -24,7 +24,6 @@ import { organizationConnectionStatusVariant } from '@/components/organizations/
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 { getUserFacingError } from '@/components/shared/formatApiError';
function formatOrganizationStatusLabel(status: string): string {
@@ -327,8 +326,6 @@ export function OrganizationsPage() {
- {!historyOpen && }
-
void handleHistoryCopy(invitation)}
- toastMessages={toast.messages}
/>
);
diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx
index 132e425..cf14834 100644
--- a/frontend/src/components/ui/patient/PatientsPage.tsx
+++ b/frontend/src/components/ui/patient/PatientsPage.tsx
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from 'react';
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 { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -125,8 +124,6 @@ export function PatientsPage() {
-
-
{isCreateOpen && (
, string> = {
- top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none',
- bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none',
+ top: 'fixed top-4 left-0 right-0 z-[100] px-4 pointer-events-none',
+ bottom:
+ 'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none',
};
/**
@@ -87,7 +91,7 @@ export function ToastViewport({
return (
);
}
diff --git a/frontend/src/components/ui/shared/ToastProvider.tsx b/frontend/src/components/ui/shared/ToastProvider.tsx
new file mode 100644
index 0000000..d98e8d0
--- /dev/null
+++ b/frontend/src/components/ui/shared/ToastProvider.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+import { createPortal } from 'react-dom';
+import {
+ ToastStack,
+ type ToastMessages,
+ type ToastViewportPosition,
+} from '@/components/ui/shared/Toast';
+
+const DEFAULT_DURATION_MS = 4000;
+
+export type UseToastOptions = {
+ successMs?: number;
+ errorMs?: number;
+ infoMs?: number;
+ defaultMs?: number;
+};
+
+export type ToastContextValue = {
+ error: string;
+ success: string;
+ info: string;
+ defaultMessage: string;
+ setError: (message: string) => void;
+ setSuccess: (message: string) => void;
+ setInfo: (message: string) => void;
+ setDefaultMessage: (message: string) => void;
+ showError: (message: string) => void;
+ showSuccess: (message: string) => void;
+ showInfo: (message: string) => void;
+ showDefault: (message: string) => void;
+ clear: () => void;
+ messages: ToastMessages;
+};
+
+const ToastContext = createContext(null);
+
+function useToastState(options: UseToastOptions = {}): ToastContextValue {
+ const successMs = options.successMs ?? DEFAULT_DURATION_MS;
+ const errorMs = options.errorMs ?? DEFAULT_DURATION_MS;
+ const infoMs = options.infoMs ?? DEFAULT_DURATION_MS;
+ const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS;
+
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const [info, setInfo] = useState('');
+ const [defaultMessage, setDefaultMessage] = useState('');
+
+ useEffect(() => {
+ if (!success) return;
+ const id = setTimeout(() => setSuccess(''), successMs);
+ return () => clearTimeout(id);
+ }, [success, successMs]);
+
+ useEffect(() => {
+ if (!error) return;
+ const id = setTimeout(() => setError(''), errorMs);
+ return () => clearTimeout(id);
+ }, [error, errorMs]);
+
+ useEffect(() => {
+ if (!info) return;
+ const id = setTimeout(() => setInfo(''), infoMs);
+ return () => clearTimeout(id);
+ }, [info, infoMs]);
+
+ useEffect(() => {
+ if (!defaultMessage) return;
+ const id = setTimeout(() => setDefaultMessage(''), defaultMs);
+ return () => clearTimeout(id);
+ }, [defaultMessage, defaultMs]);
+
+ const clear = useCallback(() => {
+ setError('');
+ setSuccess('');
+ setInfo('');
+ setDefaultMessage('');
+ }, []);
+
+ const showError = useCallback((message: string) => {
+ setSuccess('');
+ setInfo('');
+ setDefaultMessage('');
+ setError(message);
+ }, []);
+
+ const showSuccess = useCallback((message: string) => {
+ setError('');
+ setInfo('');
+ setDefaultMessage('');
+ setSuccess(message);
+ }, []);
+
+ const showInfo = useCallback((message: string) => {
+ setError('');
+ setSuccess('');
+ setDefaultMessage('');
+ setInfo(message);
+ }, []);
+
+ const showDefault = useCallback((message: string) => {
+ setError('');
+ setSuccess('');
+ setInfo('');
+ setDefaultMessage(message);
+ }, []);
+
+ const messages: ToastMessages = useMemo(
+ () => ({ error, success, info, default: defaultMessage }),
+ [error, success, info, defaultMessage],
+ );
+
+ return useMemo(
+ () => ({
+ error,
+ success,
+ info,
+ defaultMessage,
+ setError,
+ setSuccess,
+ setInfo,
+ setDefaultMessage,
+ showError,
+ showSuccess,
+ showInfo,
+ showDefault,
+ clear,
+ messages,
+ }),
+ [
+ error,
+ success,
+ info,
+ defaultMessage,
+ showError,
+ showSuccess,
+ showInfo,
+ showDefault,
+ clear,
+ messages,
+ ],
+ );
+}
+
+type ToastProviderProps = {
+ children: ReactNode;
+ /** Fixed viewport position. Default `bottom` — visible when scrolled and above dialogs. */
+ position?: Exclude;
+};
+
+function GlobalToastHost({
+ messages,
+ position,
+}: {
+ messages: ToastMessages;
+ position: Exclude;
+}) {
+ const hasMessage = Boolean(
+ messages.error || messages.success || messages.info || messages.default,
+ );
+
+ if (!hasMessage) {
+ return null;
+ }
+
+ const positionClass =
+ position === 'bottom'
+ ? 'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none'
+ : 'fixed top-4 left-0 right-0 z-[100] px-4 pt-[max(1rem,env(safe-area-inset-top))] pointer-events-none';
+
+ return createPortal(
+ ,
+ document.body,
+ );
+}
+
+export function ToastProvider({ children, position = 'bottom' }: ToastProviderProps) {
+ const toast = useToastState();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return (
+
+ {children}
+ {mounted ? : null}
+
+ );
+}
+
+/** Global app toasts — requires `` ancestor (dashboard layout). */
+export function useToast(): ToastContextValue {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within ToastProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/components/ui/staff/StaffPage.tsx b/frontend/src/components/ui/staff/StaffPage.tsx
index b8b92d2..0e19b7b 100644
--- a/frontend/src/components/ui/staff/StaffPage.tsx
+++ b/frontend/src/components/ui/staff/StaffPage.tsx
@@ -34,7 +34,6 @@ import { Badge } from '@/components/ui/shared/Badge';
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 { getUserFacingError } from '@/components/shared/formatApiError';
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
import { useToast } from '@/lib/hooks/useToast';
@@ -523,8 +522,6 @@ export function StaffPage() {
-
-
{seats && (
{t('seatsLabel')}{' '}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 7ccdc3b..c2b1827 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -9,7 +9,6 @@ import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatc
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
-import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import {
addCalendarDays,
@@ -1139,8 +1138,6 @@ export function TreatmentWorkspace({
-
-
setStripHidden((s) => !s)}
diff --git a/frontend/src/lib/hooks/useToast.ts b/frontend/src/lib/hooks/useToast.ts
index def906c..0601094 100644
--- a/frontend/src/lib/hooks/useToast.ts
+++ b/frontend/src/lib/hooks/useToast.ts
@@ -1,103 +1,2 @@
-'use client';
-
-import { useCallback, useEffect, useState } from 'react';
-import type { ToastMessages } from '@/components/ui/shared/Toast';
-
-const DEFAULT_DURATION_MS = 4000;
-
-export type UseToastOptions = {
- successMs?: number;
- errorMs?: number;
- infoMs?: number;
- defaultMs?: number;
-};
-
-export function useToast(options: UseToastOptions = {}) {
- const successMs = options.successMs ?? DEFAULT_DURATION_MS;
- const errorMs = options.errorMs ?? DEFAULT_DURATION_MS;
- const infoMs = options.infoMs ?? DEFAULT_DURATION_MS;
- const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS;
-
- const [error, setError] = useState('');
- const [success, setSuccess] = useState('');
- const [info, setInfo] = useState('');
- const [defaultMessage, setDefaultMessage] = useState('');
-
- useEffect(() => {
- if (!success) return;
- const id = setTimeout(() => setSuccess(''), successMs);
- return () => clearTimeout(id);
- }, [success, successMs]);
-
- useEffect(() => {
- if (!error) return;
- const id = setTimeout(() => setError(''), errorMs);
- return () => clearTimeout(id);
- }, [error, errorMs]);
-
- useEffect(() => {
- if (!info) return;
- const id = setTimeout(() => setInfo(''), infoMs);
- return () => clearTimeout(id);
- }, [info, infoMs]);
-
- useEffect(() => {
- if (!defaultMessage) return;
- const id = setTimeout(() => setDefaultMessage(''), defaultMs);
- return () => clearTimeout(id);
- }, [defaultMessage, defaultMs]);
-
- const clear = useCallback(() => {
- setError('');
- setSuccess('');
- setInfo('');
- setDefaultMessage('');
- }, []);
-
- const showError = useCallback((message: string) => {
- setSuccess('');
- setInfo('');
- setDefaultMessage('');
- setError(message);
- }, []);
-
- const showSuccess = useCallback((message: string) => {
- setError('');
- setInfo('');
- setDefaultMessage('');
- setSuccess(message);
- }, []);
-
- const showInfo = useCallback((message: string) => {
- setError('');
- setSuccess('');
- setDefaultMessage('');
- setInfo(message);
- }, []);
-
- const showDefault = useCallback((message: string) => {
- setError('');
- setSuccess('');
- setInfo('');
- setDefaultMessage(message);
- }, []);
-
- const messages: ToastMessages = { error, success, info, default: defaultMessage };
-
- return {
- error,
- success,
- info,
- defaultMessage,
- setError,
- setSuccess,
- setInfo,
- setDefaultMessage,
- showError,
- showSuccess,
- showInfo,
- showDefault,
- clear,
- messages,
- };
-}
+export { ToastProvider, useToast } from '@/components/ui/shared/ToastProvider';
+export type { ToastContextValue, UseToastOptions } from '@/components/ui/shared/ToastProvider';