Add global navigation search, cancellation policies, and UI improvements

- Add global search in top bar for navigating to dashboard pages
- Add cancellation policy settings (window hours, late fee, deposit refund)
- Display booking policies on customer confirmation page
- Filter API tokens by sandbox/live mode
- Widen settings layout and full-width site builder
- Add help documentation search with OpenAI integration
- Add blocked time ranges API for calendar visualization
- Update business hours settings with holiday management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-25 23:39:07 -05:00
parent 8391ecbf88
commit 416cd7059b
174 changed files with 31835 additions and 4921 deletions

View File

@@ -0,0 +1,708 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import {
staffEmailKeys,
useStaffEmailFolders,
useCreateStaffEmailFolder,
useUpdateStaffEmailFolder,
useDeleteStaffEmailFolder,
useStaffEmail,
useStaffEmailThread,
useStaffEmailLabels,
useCreateLabel,
useUpdateLabel,
useDeleteLabel,
useAddLabelToEmail,
useRemoveLabelFromEmail,
useCreateDraft,
useUpdateDraft,
useDeleteDraft,
useSendEmail,
useReplyToEmail,
useForwardEmail,
useMarkAsRead,
useMarkAsUnread,
useStarEmail,
useUnstarEmail,
useArchiveEmail,
useTrashEmail,
useRestoreEmail,
usePermanentlyDeleteEmail,
useMoveEmails,
useBulkEmailAction,
useContactSearch,
useUploadAttachment,
useDeleteAttachment,
useSyncEmails,
useFullSyncEmails,
useUserEmailAddresses,
} from '../useStaffEmail';
import * as staffEmailApi from '../../api/staffEmail';
vi.mock('../../api/staffEmail');
const mockFolder = {
id: 1,
owner: 1,
name: 'Inbox',
folderType: 'inbox',
emailCount: 10,
unreadCount: 3,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const mockEmail = {
id: 1,
folder: 1,
fromAddress: 'sender@example.com',
fromName: 'Sender Name',
toAddresses: [{ email: 'recipient@example.com', name: 'Recipient' }],
subject: 'Test Email',
snippet: 'This is a test...',
status: 'received',
isRead: false,
isStarred: false,
isImportant: false,
hasAttachments: false,
attachmentCount: 0,
threadId: 'thread-1',
emailDate: '2024-01-01T12:00:00Z',
createdAt: '2024-01-01T12:00:00Z',
labels: [],
owner: 1,
emailAddress: 1,
messageId: 'msg-1',
inReplyTo: null,
references: '',
ccAddresses: [],
bccAddresses: [],
bodyText: 'This is a test email body.',
bodyHtml: '<p>This is a test email body.</p>',
isAnswered: false,
isPermanentlyDeleted: false,
deletedAt: null,
attachments: [],
updatedAt: '2024-01-01T12:00:00Z',
};
const mockLabel = {
id: 1,
owner: 1,
name: 'Important',
color: '#ef4444',
createdAt: '2024-01-01T00:00:00Z',
};
const mockContact = {
id: 1,
owner: 1,
email: 'contact@example.com',
name: 'Contact Name',
useCount: 5,
lastUsedAt: '2024-01-01T00:00:00Z',
};
const mockAttachment = {
id: 1,
filename: 'document.pdf',
contentType: 'application/pdf',
size: 1024,
url: 'https://example.com/document.pdf',
createdAt: '2024-01-01T00:00:00Z',
};
const mockUserEmailAddress = {
id: 1,
email_address: 'user@example.com',
display_name: 'User',
color: '#3b82f6',
is_default: true,
last_check_at: '2024-01-01T00:00:00Z',
emails_processed_count: 100,
};
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useStaffEmail hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('staffEmailKeys', () => {
it('generates correct query keys', () => {
expect(staffEmailKeys.all).toEqual(['staffEmail']);
expect(staffEmailKeys.folders()).toEqual(['staffEmail', 'folders']);
expect(staffEmailKeys.emails()).toEqual(['staffEmail', 'emails']);
expect(staffEmailKeys.emailDetail(1)).toEqual(['staffEmail', 'emails', 'detail', 1]);
expect(staffEmailKeys.emailThread('thread-1')).toEqual(['staffEmail', 'emails', 'thread', 'thread-1']);
expect(staffEmailKeys.labels()).toEqual(['staffEmail', 'labels']);
expect(staffEmailKeys.contacts('test')).toEqual(['staffEmail', 'contacts', 'test']);
expect(staffEmailKeys.userEmailAddresses()).toEqual(['staffEmail', 'userEmailAddresses']);
});
it('generates email list key with filters', () => {
const filters = { folderId: 1, emailAddressId: 2, search: 'test' };
const key = staffEmailKeys.emailList(filters);
expect(key).toContain('staffEmail');
expect(key).toContain('emails');
expect(key).toContain('list');
});
});
describe('useStaffEmailFolders', () => {
it('fetches email folders', async () => {
vi.mocked(staffEmailApi.getFolders).mockResolvedValueOnce([mockFolder]);
const { result } = renderHook(() => useStaffEmailFolders(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.getFolders).toHaveBeenCalled();
expect(result.current.data).toEqual([mockFolder]);
});
it('handles error when fetching folders', async () => {
vi.mocked(staffEmailApi.getFolders).mockRejectedValueOnce(new Error('Failed to fetch folders'));
const { result } = renderHook(() => useStaffEmailFolders(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useCreateStaffEmailFolder', () => {
it('creates a new folder', async () => {
const newFolder = { ...mockFolder, id: 2, name: 'Custom Folder' };
vi.mocked(staffEmailApi.createFolder).mockResolvedValueOnce(newFolder);
const { result } = renderHook(() => useCreateStaffEmailFolder(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync('Custom Folder');
});
expect(staffEmailApi.createFolder).toHaveBeenCalledWith('Custom Folder');
});
});
describe('useUpdateStaffEmailFolder', () => {
it('updates a folder name', async () => {
const updatedFolder = { ...mockFolder, name: 'Updated Name' };
vi.mocked(staffEmailApi.updateFolder).mockResolvedValueOnce(updatedFolder);
const { result } = renderHook(() => useUpdateStaffEmailFolder(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, name: 'Updated Name' });
});
expect(staffEmailApi.updateFolder).toHaveBeenCalledWith(1, 'Updated Name');
});
});
describe('useDeleteStaffEmailFolder', () => {
it('deletes a folder', async () => {
vi.mocked(staffEmailApi.deleteFolder).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useDeleteStaffEmailFolder(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.deleteFolder).toHaveBeenCalledWith(1);
});
});
describe('useStaffEmail', () => {
it('fetches a single email by id', async () => {
vi.mocked(staffEmailApi.getEmail).mockResolvedValueOnce(mockEmail);
const { result } = renderHook(() => useStaffEmail(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.getEmail).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockEmail);
});
it('does not fetch when id is undefined', () => {
const { result } = renderHook(() => useStaffEmail(undefined), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(staffEmailApi.getEmail).not.toHaveBeenCalled();
});
});
describe('useStaffEmailThread', () => {
it('fetches email thread', async () => {
vi.mocked(staffEmailApi.getEmailThread).mockResolvedValueOnce([mockEmail]);
const { result } = renderHook(() => useStaffEmailThread('thread-1'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.getEmailThread).toHaveBeenCalledWith('thread-1');
expect(result.current.data).toEqual([mockEmail]);
});
it('does not fetch when threadId is undefined', () => {
const { result } = renderHook(() => useStaffEmailThread(undefined), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(staffEmailApi.getEmailThread).not.toHaveBeenCalled();
});
});
describe('useStaffEmailLabels', () => {
it('fetches email labels', async () => {
vi.mocked(staffEmailApi.getLabels).mockResolvedValueOnce([mockLabel]);
const { result } = renderHook(() => useStaffEmailLabels(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.getLabels).toHaveBeenCalled();
expect(result.current.data).toEqual([mockLabel]);
});
});
describe('useCreateLabel', () => {
it('creates a new label', async () => {
const newLabel = { ...mockLabel, id: 2, name: 'Work', color: '#10b981' };
vi.mocked(staffEmailApi.createLabel).mockResolvedValueOnce(newLabel);
const { result } = renderHook(() => useCreateLabel(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ name: 'Work', color: '#10b981' });
});
expect(staffEmailApi.createLabel).toHaveBeenCalledWith('Work', '#10b981');
});
});
describe('useUpdateLabel', () => {
it('updates a label', async () => {
const updatedLabel = { ...mockLabel, name: 'Updated Label' };
vi.mocked(staffEmailApi.updateLabel).mockResolvedValueOnce(updatedLabel);
const { result } = renderHook(() => useUpdateLabel(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, data: { name: 'Updated Label' } });
});
expect(staffEmailApi.updateLabel).toHaveBeenCalledWith(1, { name: 'Updated Label' });
});
});
describe('useDeleteLabel', () => {
it('deletes a label', async () => {
vi.mocked(staffEmailApi.deleteLabel).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useDeleteLabel(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.deleteLabel).toHaveBeenCalledWith(1);
});
});
describe('useAddLabelToEmail', () => {
it('adds label to email', async () => {
vi.mocked(staffEmailApi.addLabelToEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useAddLabelToEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ emailId: 1, labelId: 2 });
});
expect(staffEmailApi.addLabelToEmail).toHaveBeenCalledWith(1, 2);
});
});
describe('useRemoveLabelFromEmail', () => {
it('removes label from email', async () => {
vi.mocked(staffEmailApi.removeLabelFromEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useRemoveLabelFromEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ emailId: 1, labelId: 2 });
});
expect(staffEmailApi.removeLabelFromEmail).toHaveBeenCalledWith(1, 2);
});
});
describe('useCreateDraft', () => {
it('creates a draft email', async () => {
vi.mocked(staffEmailApi.createDraft).mockResolvedValueOnce(mockEmail);
const draftData = {
emailAddressId: 1,
toAddresses: ['recipient@example.com'],
subject: 'Test Draft',
bodyText: 'Draft body',
};
const { result } = renderHook(() => useCreateDraft(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(draftData);
});
expect(staffEmailApi.createDraft).toHaveBeenCalledWith(draftData);
});
});
describe('useUpdateDraft', () => {
it('updates a draft email', async () => {
vi.mocked(staffEmailApi.updateDraft).mockResolvedValueOnce(mockEmail);
const { result } = renderHook(() => useUpdateDraft(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, data: { subject: 'Updated Subject' } });
});
expect(staffEmailApi.updateDraft).toHaveBeenCalledWith(1, { subject: 'Updated Subject' });
});
});
describe('useDeleteDraft', () => {
it('deletes a draft', async () => {
vi.mocked(staffEmailApi.deleteDraft).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useDeleteDraft(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.deleteDraft).toHaveBeenCalledWith(1);
});
});
describe('useSendEmail', () => {
it('sends an email', async () => {
vi.mocked(staffEmailApi.sendEmail).mockResolvedValueOnce(mockEmail);
const { result } = renderHook(() => useSendEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.sendEmail).toHaveBeenCalledWith(1);
});
});
describe('useReplyToEmail', () => {
it('replies to an email', async () => {
vi.mocked(staffEmailApi.replyToEmail).mockResolvedValueOnce(mockEmail);
const replyData = {
bodyText: 'Reply body',
bodyHtml: '<p>Reply body</p>',
replyAll: false,
};
const { result } = renderHook(() => useReplyToEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, data: replyData });
});
expect(staffEmailApi.replyToEmail).toHaveBeenCalledWith(1, replyData);
});
});
describe('useForwardEmail', () => {
it('forwards an email', async () => {
vi.mocked(staffEmailApi.forwardEmail).mockResolvedValueOnce(mockEmail);
const forwardData = {
toAddresses: ['forward@example.com'],
bodyText: 'Forwarding this email',
};
const { result } = renderHook(() => useForwardEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, data: forwardData });
});
expect(staffEmailApi.forwardEmail).toHaveBeenCalledWith(1, forwardData);
});
});
describe('useMarkAsRead', () => {
it('marks email as read', async () => {
vi.mocked(staffEmailApi.markAsRead).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useMarkAsRead(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.markAsRead).toHaveBeenCalledWith(1);
});
});
describe('useMarkAsUnread', () => {
it('marks email as unread', async () => {
vi.mocked(staffEmailApi.markAsUnread).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useMarkAsUnread(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.markAsUnread).toHaveBeenCalledWith(1);
});
});
describe('useStarEmail', () => {
it('stars an email', async () => {
vi.mocked(staffEmailApi.starEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useStarEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.starEmail).toHaveBeenCalledWith(1);
});
});
describe('useUnstarEmail', () => {
it('unstars an email', async () => {
vi.mocked(staffEmailApi.unstarEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useUnstarEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.unstarEmail).toHaveBeenCalledWith(1);
});
});
describe('useArchiveEmail', () => {
it('archives an email', async () => {
vi.mocked(staffEmailApi.archiveEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useArchiveEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.archiveEmail).toHaveBeenCalledWith(1);
});
});
describe('useTrashEmail', () => {
it('moves email to trash', async () => {
vi.mocked(staffEmailApi.trashEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useTrashEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.trashEmail).toHaveBeenCalledWith(1);
});
});
describe('useRestoreEmail', () => {
it('restores an email from trash', async () => {
vi.mocked(staffEmailApi.restoreEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useRestoreEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.restoreEmail).toHaveBeenCalledWith(1);
});
});
describe('usePermanentlyDeleteEmail', () => {
it('permanently deletes an email', async () => {
vi.mocked(staffEmailApi.permanentlyDeleteEmail).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => usePermanentlyDeleteEmail(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.permanentlyDeleteEmail).toHaveBeenCalledWith(1);
});
});
describe('useMoveEmails', () => {
it('moves emails to a folder', async () => {
vi.mocked(staffEmailApi.moveEmails).mockResolvedValueOnce(undefined);
const moveData = { emailIds: [1, 2, 3], folderId: 2 };
const { result } = renderHook(() => useMoveEmails(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(moveData);
});
expect(staffEmailApi.moveEmails).toHaveBeenCalledWith(moveData);
});
});
describe('useBulkEmailAction', () => {
it('performs bulk action on emails', async () => {
vi.mocked(staffEmailApi.bulkAction).mockResolvedValueOnce(undefined);
const bulkData = { emailIds: [1, 2, 3], action: 'mark_read' as const };
const { result } = renderHook(() => useBulkEmailAction(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(bulkData);
});
expect(staffEmailApi.bulkAction).toHaveBeenCalledWith(bulkData);
});
});
describe('useContactSearch', () => {
it('searches contacts with query', async () => {
vi.mocked(staffEmailApi.searchContacts).mockResolvedValueOnce([mockContact]);
const { result } = renderHook(() => useContactSearch('test'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.searchContacts).toHaveBeenCalledWith('test');
expect(result.current.data).toEqual([mockContact]);
});
it('does not search with query less than 2 characters', () => {
const { result } = renderHook(() => useContactSearch('t'), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(staffEmailApi.searchContacts).not.toHaveBeenCalled();
});
});
describe('useUploadAttachment', () => {
it('uploads an attachment', async () => {
vi.mocked(staffEmailApi.uploadAttachment).mockResolvedValueOnce(mockAttachment);
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const { result } = renderHook(() => useUploadAttachment(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ file, emailId: 1 });
});
expect(staffEmailApi.uploadAttachment).toHaveBeenCalledWith(file, 1);
});
it('uploads attachment without email id', async () => {
vi.mocked(staffEmailApi.uploadAttachment).mockResolvedValueOnce(mockAttachment);
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const { result } = renderHook(() => useUploadAttachment(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ file });
});
expect(staffEmailApi.uploadAttachment).toHaveBeenCalledWith(file, undefined);
});
});
describe('useDeleteAttachment', () => {
it('deletes an attachment', async () => {
vi.mocked(staffEmailApi.deleteAttachment).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useDeleteAttachment(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(staffEmailApi.deleteAttachment).toHaveBeenCalledWith(1);
});
});
describe('useSyncEmails', () => {
it('syncs emails', async () => {
vi.mocked(staffEmailApi.syncEmails).mockResolvedValueOnce({ success: true, message: 'Synced' });
const { result } = renderHook(() => useSyncEmails(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync();
});
expect(staffEmailApi.syncEmails).toHaveBeenCalled();
});
});
describe('useFullSyncEmails', () => {
it('performs full email sync', async () => {
vi.mocked(staffEmailApi.fullSyncEmails).mockResolvedValueOnce({
status: 'started',
tasks: [{ email_address: 'user@example.com', task_id: 'task-1' }],
});
const { result } = renderHook(() => useFullSyncEmails(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync();
});
expect(staffEmailApi.fullSyncEmails).toHaveBeenCalled();
});
});
describe('useUserEmailAddresses', () => {
it('fetches user email addresses', async () => {
vi.mocked(staffEmailApi.getUserEmailAddresses).mockResolvedValueOnce([mockUserEmailAddress]);
const { result } = renderHook(() => useUserEmailAddresses(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(staffEmailApi.getUserEmailAddresses).toHaveBeenCalled();
expect(result.current.data).toEqual([mockUserEmailAddress]);
});
});
});