improvement: updated toasts. no invisible toast anymore.
This commit is contained in:
@@ -40,6 +40,12 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork
|
|||||||
- API clients: `lib/api/`.
|
- API clients: `lib/api/`.
|
||||||
- Catch blocks: `getUserFacingError(err, tErrors, fallback)` from `components/shared/formatApiError.ts`.
|
- 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
|
## When adding UI
|
||||||
|
|
||||||
1. Check `components/ui/shared/` for an existing primitive.
|
1. Check `components/ui/shared/` for an existing primitive.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth';
|
|||||||
import Sidebar from '@/components/ui/shared/Sidebar';
|
import Sidebar from '@/components/ui/shared/Sidebar';
|
||||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||||
|
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
|
||||||
import {
|
import {
|
||||||
canAccessDashboardRoute,
|
canAccessDashboardRoute,
|
||||||
firstAccessibleDashboardPath,
|
firstAccessibleDashboardPath,
|
||||||
@@ -70,7 +71,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] app-web-bg text-text-primary">
|
<ToastProvider>
|
||||||
|
<div className="flex h-[100dvh] app-web-bg text-text-primary">
|
||||||
{sidebarOpen ? (
|
{sidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -94,7 +96,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen
|
|||||||
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
||||||
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
@@ -286,8 +285,6 @@ export function AppointmentsPage() {
|
|||||||
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
|
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastStack {...toast.messages} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="xl:col-span-1 space-y-4">
|
<div className="xl:col-span-1 space-y-4">
|
||||||
<AppointmentsPatientSearch
|
<AppointmentsPatientSearch
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
@@ -466,7 +465,6 @@ export function CasesPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastStack {...toast.messages} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
@@ -397,7 +396,6 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ToastStack {...toastMessages} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentType
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import {
|
import {
|
||||||
@@ -210,8 +209,6 @@ export function ConnectionCaseHistoryContent({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastStack {...toastMessages} />
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
|
||||||
<section
|
<section
|
||||||
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
|
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import {
|
|||||||
ResponsiveDialogOverlay,
|
ResponsiveDialogOverlay,
|
||||||
ResponsiveDialogPanel,
|
ResponsiveDialogPanel,
|
||||||
} from '@/components/ui/shared/ResponsiveDialog';
|
} 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 type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant';
|
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 {
|
function formatTableDate(value: string): string {
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -28,8 +27,6 @@ type InvitationHistoryDialogProps = {
|
|||||||
copiedId: string | null;
|
copiedId: string | null;
|
||||||
copyingInvitationId: string | null;
|
copyingInvitationId: string | null;
|
||||||
onCopy: (invitation: OrganizationInvitationHistoryItemDto) => void;
|
onCopy: (invitation: OrganizationInvitationHistoryItemDto) => void;
|
||||||
/** Same page-level toasts, rendered at top of dialog while it is open. */
|
|
||||||
toastMessages?: ToastMessages;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvitationHistoryDialog({
|
export function InvitationHistoryDialog({
|
||||||
@@ -40,7 +37,6 @@ export function InvitationHistoryDialog({
|
|||||||
copiedId,
|
copiedId,
|
||||||
copyingInvitationId,
|
copyingInvitationId,
|
||||||
onCopy,
|
onCopy,
|
||||||
toastMessages,
|
|
||||||
}: InvitationHistoryDialogProps) {
|
}: InvitationHistoryDialogProps) {
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
|
|
||||||
@@ -72,8 +68,6 @@ export function InvitationHistoryDialog({
|
|||||||
<DialogCloseButton onClick={onClose} />
|
<DialogCloseButton onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toastMessages && <ToastStack {...toastMessages} />}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-text-secondary">{t('loadingHistory')}</p>
|
<p className="text-sm text-text-secondary">{t('loadingHistory')}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { organizationConnectionStatusVariant } from '@/components/organizations/
|
|||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { Table } from '@/components/ui/shared/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
|
|
||||||
function formatOrganizationStatusLabel(status: string): string {
|
function formatOrganizationStatusLabel(status: string): string {
|
||||||
@@ -327,8 +326,6 @@ export function OrganizationsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!historyOpen && <ToastStack {...toast.messages} />}
|
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={query}
|
value={query}
|
||||||
onChange={setQuery}
|
onChange={setQuery}
|
||||||
@@ -596,7 +593,6 @@ export function OrganizationsPage() {
|
|||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
copyingInvitationId={copyingInvitationId}
|
copyingInvitationId={copyingInvitationId}
|
||||||
onCopy={(invitation) => void handleHistoryCopy(invitation)}
|
onCopy={(invitation) => void handleHistoryCopy(invitation)}
|
||||||
toastMessages={toast.messages}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
@@ -125,8 +124,6 @@ export function PatientsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastStack {...toast.messages} />
|
|
||||||
|
|
||||||
{isCreateOpen && (
|
{isCreateOpen && (
|
||||||
<CreatePatientModal
|
<CreatePatientModal
|
||||||
isOpen={isCreateOpen}
|
isOpen={isCreateOpen}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ function hasToastMessages(messages: ToastMessages): boolean {
|
|||||||
return Boolean(messages.error || messages.success || messages.info || messages.default);
|
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) {
|
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
|
||||||
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
|
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
|
||||||
return null;
|
return null;
|
||||||
@@ -63,8 +66,9 @@ export type ToastViewportProps = ToastStackProps & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
|
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
|
||||||
top: 'fixed top-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-[70] 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 (
|
return (
|
||||||
<div className={viewportPositionClass[position]}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
213
frontend/src/components/ui/shared/ToastProvider.tsx
Normal file
213
frontend/src/components/ui/shared/ToastProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ import { Badge } from '@/components/ui/shared/Badge';
|
|||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Table } from '@/components/ui/shared/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
|
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
@@ -523,8 +522,6 @@ export function StaffPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastStack {...toast.messages} />
|
|
||||||
|
|
||||||
{seats && (
|
{seats && (
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('seatsLabel')}{' '}
|
{t('seatsLabel')}{' '}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatc
|
|||||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||||
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
||||||
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
|
||||||
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||||
import {
|
import {
|
||||||
addCalendarDays,
|
addCalendarDays,
|
||||||
@@ -1139,8 +1138,6 @@ export function TreatmentWorkspace({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ToastStack {...toastMessages} />
|
|
||||||
|
|
||||||
<AppointmentsStrip
|
<AppointmentsStrip
|
||||||
stripHidden={stripHidden}
|
stripHidden={stripHidden}
|
||||||
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
||||||
|
|||||||
@@ -1,103 +1,2 @@
|
|||||||
'use client';
|
export { ToastProvider, useToast } from '@/components/ui/shared/ToastProvider';
|
||||||
|
export type { ToastContextValue, UseToastOptions } from '@/components/ui/shared/ToastProvider';
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user