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 ( -
+ +
{sidebarOpen ? (
-
+ + ); } 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 (
-
{stack}
+
{stack}
); } 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';