From 2025a868ea7ccd956445c2b55edaaf250274ad80 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 17 May 2026 13:59:00 +0330 Subject: [PATCH] bugfix: all the toasts unfied inside organizations feature. other features still need a refactor for toasts though. --- .../app/(dashboard)/organizations/page.tsx | 88 ++++++--------- frontend/src/components/ui/common/Toast.tsx | 67 ++++++++++++ .../organizations/InvitationHistoryDialog.tsx | 20 +--- frontend/src/lib/hooks/useToast.ts | 103 ++++++++++++++++++ 4 files changed, 206 insertions(+), 72 deletions(-) create mode 100644 frontend/src/lib/hooks/useToast.ts diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 0ce5451..d581d28 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,6 +1,7 @@ -'use client'; +'use client'; import { useEffect, useState } from 'react'; +import { useToast } from '@/lib/hooks/useToast'; import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; @@ -18,6 +19,7 @@ import { Badge, organizationConnectionStatusVariant } from '@/components/ui/comm import { Input } from '@/components/ui/common/Input'; import { SearchBar } from '@/components/ui/common/SearchBar'; import { Table } from '@/components/ui/common/Table'; +import { ToastStack } from '@/components/ui/common/Toast'; import type { ApiError } from '@/types/api'; function formatOrganizationStatusLabel(status: string): string { @@ -63,8 +65,7 @@ type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); + const toast = useToast(); const [query, setQuery] = useState(''); const [mode, setMode] = useState('existing'); @@ -81,8 +82,6 @@ export default function OrganizationsPage() { const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); - const [historyCopyError, setHistoryCopyError] = useState(''); - const [historyCopySuccess, setHistoryCopySuccess] = useState(''); const { copiedId, @@ -99,12 +98,12 @@ export default function OrganizationsPage() { async function loadList() { setLoading(true); - setError(''); + toast.setError(''); try { const res = await organizationApi.list(); setItems(res.data.items); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setLoading(false); } @@ -114,12 +113,6 @@ export default function OrganizationsPage() { void loadList(); }, []); - useEffect(() => { - if (!success) return; - const t = setTimeout(() => setSuccess(''), 4000); - return () => clearTimeout(t); - }, [success]); - async function runSearch() { const q = query.trim(); if (!q) { @@ -130,14 +123,14 @@ export default function OrganizationsPage() { } setSearching(true); - setError(''); + toast.setError(''); setMode('search'); setShowInviteForm(false); try { const res = await organizationApi.search(q); setSearchResults(res.data); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); setSearchResults([]); } finally { setSearching(false); @@ -146,16 +139,16 @@ export default function OrganizationsPage() { async function submitConnectionRequest(targetOrganizationId: string) { setPendingConnectionRowId(targetOrganizationId); - setError(''); + toast.setError(''); try { await organizationApi.createConnectionRequest(targetOrganizationId); - setSuccess(`${counterpartLabel} connection request sent.`); + toast.showSuccess(`${counterpartLabel} connection request sent.`); setSearchResults([]); setQuery(''); setMode('existing'); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setPendingConnectionRowId(null); } @@ -163,14 +156,14 @@ export default function OrganizationsPage() { async function sendInvite() { setInviteLoading(true); - setError(''); + toast.setError(''); try { const res = await organizationApi.invite({ organizationName: manualOrganizationName.trim(), ownerEmail: manualOwnerEmail.trim(), }); storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); - setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); + toast.showSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); setManualOrganizationName(''); setManualOwnerEmail(''); setShowInviteForm(false); @@ -179,7 +172,7 @@ export default function OrganizationsPage() { setSearchResults([]); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setInviteLoading(false); } @@ -195,38 +188,34 @@ export default function OrganizationsPage() { async function openInvitationHistory() { setHistoryOpen(true); setHistoryLoading(true); - setHistoryCopyError(''); - setHistoryCopySuccess(''); - setError(''); + toast.clear(); try { await loadInvitationHistory(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setHistoryLoading(false); } } async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { - setHistoryCopyError(''); - setHistoryCopySuccess(''); + toast.setError(''); try { await copyInvitationLink(invitation, { onRegenerated: async () => { await loadInvitationHistory(); }, }); - setHistoryCopySuccess('Invitation link copied to clipboard.'); - setTimeout(() => setHistoryCopySuccess(''), 3000); + toast.showSuccess('Invitation link copied to clipboard.'); } catch (e) { - setHistoryCopyError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } } async function handleCopyInvitationFromRow(row: CounterpartItemDto) { const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); if (!target) return; - setError(''); + toast.setError(''); try { await copyInvitationLink( { @@ -243,24 +232,23 @@ export default function OrganizationsPage() { }, }, ); - setSuccess('Invitation link copied to clipboard.'); - setTimeout(() => setSuccess(''), 3000); + toast.showSuccess('Invitation link copied to clipboard.'); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } } async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { setPendingConnectionRowId(connectionId); - setError(''); + toast.setError(''); try { await organizationApi.respondToConnectionRequest(connectionId, action); - setSuccess( + toast.showSuccess( action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.', ); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setPendingConnectionRowId(null); } @@ -268,13 +256,13 @@ export default function OrganizationsPage() { async function deleteConnection(connectionId: string) { setDeleteConnectionRowId(connectionId); - setError(''); + toast.setError(''); try { await organizationApi.deleteConnection(connectionId); - setSuccess('Connection removed.'); + toast.showSuccess('Connection removed.'); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setDeleteConnectionRowId(null); } @@ -306,16 +294,7 @@ export default function OrganizationsPage() { - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} + {!historyOpen && } { - setHistoryOpen(false); - setHistoryCopyError(''); - setHistoryCopySuccess(''); - }} + onClose={() => setHistoryOpen(false)} loading={historyLoading} items={historyItems} copiedId={copiedId} copyingInvitationId={copyingInvitationId} onCopy={(invitation) => void handleHistoryCopy(invitation)} - copyError={historyCopyError} - copySuccess={historyCopySuccess} + toastMessages={toast.messages} /> ); diff --git a/frontend/src/components/ui/common/Toast.tsx b/frontend/src/components/ui/common/Toast.tsx index 094531e..f3073ca 100644 --- a/frontend/src/components/ui/common/Toast.tsx +++ b/frontend/src/components/ui/common/Toast.tsx @@ -24,3 +24,70 @@ export function Toast({ children, variant = 'default', className = '' }: ToastPr ); } + +export type ToastMessages = { + error?: string; + success?: string; + info?: string; + default?: string; +}; + +export type ToastStackProps = ToastMessages & { + className?: string; +}; + +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). */ +export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) { + if (!hasToastMessages({ error, success, info, default: defaultMessage })) { + return null; + } + + return ( +
+ {error && {error}} + {info && {info}} + {success && {success}} + {defaultMessage && {defaultMessage}} +
+ ); +} + +export type ToastViewportPosition = 'inline' | 'top' | 'bottom'; + +export type ToastViewportProps = ToastStackProps & { + position?: ToastViewportPosition; +}; + +const viewportPositionClass: Record, 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', +}; + +/** + * Positions a ToastStack on the page. Use `inline` below a heading; `bottom` / `top` for overlays. + */ +export function ToastViewport({ + position = 'inline', + className = '', + ...messages +}: ToastViewportProps) { + if (!hasToastMessages(messages)) { + return null; + } + + const stack = ; + + if (position === 'inline') { + return stack; + } + + return ( +
+
{stack}
+
+ ); +} diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index fcd3ee7..1e04b42 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -1,6 +1,7 @@ 'use client'; import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; +import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; import { Table } from '@/components/ui/common/Table'; @@ -28,8 +29,8 @@ type InvitationHistoryDialogProps = { copiedId: string | null; copyingInvitationId: string | null; onCopy: (invitation: OrganizationInvitationHistoryItemDto) => void; - copyError?: string; - copySuccess?: string; + /** Same page-level toasts, rendered at top of dialog while it is open. */ + toastMessages?: ToastMessages; }; export function InvitationHistoryDialog({ @@ -40,8 +41,7 @@ export function InvitationHistoryDialog({ copiedId, copyingInvitationId, onCopy, - copyError, - copySuccess, + toastMessages, }: InvitationHistoryDialogProps) { if (!open) return null; @@ -60,17 +60,7 @@ export function InvitationHistoryDialog({ - {copyError && ( -
- {copyError} -
- )} - - {copySuccess && ( -
- {copySuccess} -
- )} + {toastMessages && } {loading ? (

Loading invitation history...

diff --git a/frontend/src/lib/hooks/useToast.ts b/frontend/src/lib/hooks/useToast.ts new file mode 100644 index 0000000..4d36e78 --- /dev/null +++ b/frontend/src/lib/hooks/useToast.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ToastMessages } from '@/components/ui/common/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, + }; +}