bugfix/demo-bugs-fixed #18

Merged
admin merged 9 commits from bugfix/demo-bugs-fixed into master 2026-05-17 18:07:53 +03:30
4 changed files with 206 additions and 72 deletions
Showing only changes of commit 2025a868ea - Show all commits

View File

@@ -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<TableMode>('existing');
@@ -81,8 +82,6 @@ export default function OrganizationsPage() {
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
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() {
</Button>
</div>
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
@@ -530,18 +509,13 @@ export default function OrganizationsPage() {
<InvitationHistoryDialog
open={historyOpen}
onClose={() => {
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}
/>
</div>
);

View File

@@ -24,3 +24,70 @@ export function Toast({ children, variant = 'default', className = '' }: ToastPr
</div>
);
}
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 (
<div className={`space-y-2 ${className}`.trim()} aria-live="polite">
{error && <Toast variant="danger">{error}</Toast>}
{info && <Toast variant="warning">{info}</Toast>}
{success && <Toast variant="success">{success}</Toast>}
{defaultMessage && <Toast variant="default">{defaultMessage}</Toast>}
</div>
);
}
export type ToastViewportPosition = 'inline' | 'top' | 'bottom';
export type ToastViewportProps = ToastStackProps & {
position?: ToastViewportPosition;
};
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',
};
/**
* 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 = <ToastStack {...messages} className={className} />;
if (position === 'inline') {
return stack;
}
return (
<div className={viewportPositionClass[position]}>
<div className="pointer-events-auto w-full">{stack}</div>
</div>
);
}

View File

@@ -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({
<DialogCloseButton onClick={onClose} />
</div>
{copyError && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{copyError}
</div>
)}
{copySuccess && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{copySuccess}
</div>
)}
{toastMessages && <ToastStack {...toastMessages} />}
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation history...</p>

View File

@@ -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,
};
}