improvement: updated toasts. no invisible toast anymore.

This commit is contained in:
2026-07-12 21:30:28 +03:30
parent 540a339723
commit 53b43f2cdb
14 changed files with 236 additions and 140 deletions

View File

@@ -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 `<ToastStack />` 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.

View File

@@ -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,6 +71,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
return (
<ToastProvider>
<div className="flex h-[100dvh] app-web-bg text-text-primary">
{sidebarOpen ? (
<button
@@ -95,6 +97,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</main>
</div>
</div>
</ToastProvider>
);
}

View File

@@ -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() {
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</div>
<ToastStack {...toast.messages} />
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
<AppointmentsPatientSearch

View File

@@ -3,7 +3,6 @@
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 { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
@@ -466,7 +465,6 @@ export function CasesPage() {
</section>
</div>
<ToastStack {...toast.messages} />
</div>
);
}

View File

@@ -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() {
</div>
)}
<ToastStack {...toastMessages} />
</div>
);
}

View File

@@ -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({
</p>
</div>
<ToastStack {...toastMessages} />
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
<section
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${

View File

@@ -7,12 +7,11 @@ import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { Badge } from '@/components/ui/shared/Badge';
import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant';
import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
function formatTableDate(value: string): string {
const d = new Date(value);
@@ -28,8 +27,6 @@ type InvitationHistoryDialogProps = {
copiedId: string | null;
copyingInvitationId: string | null;
onCopy: (invitation: OrganizationInvitationHistoryItemDto) => 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({
<DialogCloseButton onClick={onClose} />
</div>
{toastMessages && <ToastStack {...toastMessages} />}
{loading ? (
<p className="text-sm text-text-secondary">{t('loadingHistory')}</p>
) : items.length === 0 ? (

View File

@@ -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() {
</Button>
</div>
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
onChange={setQuery}
@@ -596,7 +593,6 @@ export function OrganizationsPage() {
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
onCopy={(invitation) => void handleHistoryCopy(invitation)}
toastMessages={toast.messages}
/>
</div>
);

View File

@@ -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() {
</Button>
</div>
<ToastStack {...toast.messages} />
{isCreateOpen && (
<CreatePatientModal
isOpen={isCreateOpen}

View File

@@ -40,7 +40,10 @@ function hasToastMessages(messages: ToastMessages): boolean {
return Boolean(messages.error || messages.success || messages.info || messages.default);
}
/** Renders active toast messages with shared badge colors (success / warning / danger / default). */
/**
* Inline toast stack — prefer global `ToastProvider` + `useToast()` for page feedback.
* Dialog/form field errors should stay inline near the control.
*/
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
return null;
@@ -63,8 +66,9 @@ export type ToastViewportProps = ToastStackProps & {
};
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, 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 (
<div className={viewportPositionClass[position]}>
<div className="pointer-events-auto w-full">{stack}</div>
<div className="pointer-events-auto mx-auto w-full max-w-md">{stack}</div>
</div>
);
}

View File

@@ -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<ToastContextValue | null>(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<ToastViewportPosition, 'inline'>;
};
function GlobalToastHost({
messages,
position,
}: {
messages: ToastMessages;
position: Exclude<ToastViewportPosition, 'inline'>;
}) {
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(
<div className={positionClass} aria-live="polite">
<div className="pointer-events-auto mx-auto w-full max-w-md">
<ToastStack {...messages} />
</div>
</div>,
document.body,
);
}
export function ToastProvider({ children, position = 'bottom' }: ToastProviderProps) {
const toast = useToastState();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
{mounted ? <GlobalToastHost messages={toast.messages} position={position} /> : null}
</ToastContext.Provider>
);
}
/** Global app toasts — requires `<ToastProvider>` ancestor (dashboard layout). */
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -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() {
</Button>
</div>
<ToastStack {...toast.messages} />
{seats && (
<p className="text-sm text-text-secondary">
{t('seatsLabel')}{' '}

View File

@@ -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({
</p>
</header>
<ToastStack {...toastMessages} />
<AppointmentsStrip
stripHidden={stripHidden}
onToggleStripHidden={() => setStripHidden((s) => !s)}

View File

@@ -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';