diff --git a/frontend/email-page-debug.png b/frontend/email-page-debug.png new file mode 100644 index 00000000..0adf2282 Binary files /dev/null and b/frontend/email-page-debug.png differ diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html index 56e39e23..b6bb8ffd 100644 --- a/frontend/playwright-report/index.html +++ b/frontend/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cdd816f..a2eb9ef9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,6 +65,7 @@ const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers')) const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff')); const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings')); const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement')); +const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail')); const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings')); const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail')); const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired')); @@ -543,6 +544,7 @@ const AppContent: React.FC = () => { )} } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cdac322a..e23f5134 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -88,7 +88,7 @@ apiClient.interceptors.response.use( return apiClient(originalRequest); } } catch (refreshError) { - // Refresh failed - clear tokens and redirect to login on root domain + // Refresh failed - clear tokens and redirect to appropriate login page const { deleteCookie } = await import('../utils/cookies'); const { getBaseDomain } = await import('../utils/domain'); deleteCookie('access_token'); @@ -96,7 +96,16 @@ apiClient.interceptors.response.use( const protocol = window.location.protocol; const baseDomain = getBaseDomain(); const port = window.location.port ? `:${window.location.port}` : ''; - window.location.href = `${protocol}//${baseDomain}${port}/login`; + const hostname = window.location.hostname; + + // Check if on platform subdomain + if (hostname.startsWith('platform.')) { + // Platform users go to platform login page + window.location.href = `${protocol}//platform.${baseDomain}${port}/platform/login`; + } else { + // Business users go to their subdomain's login page + window.location.href = `${protocol}//${hostname}${port}/login`; + } return Promise.reject(refreshError); } } diff --git a/frontend/src/api/platformEmailAddresses.ts b/frontend/src/api/platformEmailAddresses.ts index 123a60d5..3d196cef 100644 --- a/frontend/src/api/platformEmailAddresses.ts +++ b/frontend/src/api/platformEmailAddresses.ts @@ -61,6 +61,7 @@ export interface PlatformEmailAddressListItem { email_address: string; color: string; assigned_user?: AssignedUser | null; + routing_mode: 'PLATFORM' | 'STAFF'; is_active: boolean; is_default: boolean; mail_server_synced: boolean; @@ -78,6 +79,7 @@ export interface PlatformEmailAddressCreate { domain: string; color: string; password: string; + routing_mode?: 'PLATFORM' | 'STAFF'; is_active: boolean; is_default: boolean; } @@ -88,6 +90,7 @@ export interface PlatformEmailAddressUpdate { assigned_user_id?: number | null; color?: string; password?: string; + routing_mode?: 'PLATFORM' | 'STAFF'; is_active?: boolean; is_default?: boolean; } diff --git a/frontend/src/api/staffEmail.ts b/frontend/src/api/staffEmail.ts new file mode 100644 index 00000000..f5ae3c03 --- /dev/null +++ b/frontend/src/api/staffEmail.ts @@ -0,0 +1,442 @@ +/** + * Staff Email API Client + * + * Provides API functions for the platform staff email client. + * This is for platform users (superuser, platform_manager, platform_support) + * who have been assigned email addresses in staff routing mode. + */ + +import apiClient from './client'; +import { + StaffEmailFolder, + StaffEmail, + StaffEmailListItem, + StaffEmailLabel, + StaffEmailAttachment, + StaffEmailFilters, + StaffEmailCreateDraft, + StaffEmailMove, + StaffEmailBulkAction, + StaffEmailReply, + StaffEmailForward, + EmailContactSuggestion, + StaffEmailStats, +} from '../types'; + +const BASE_URL = '/staff-email'; + +// ============================================================================ +// Folders +// ============================================================================ + +export const getFolders = async (): Promise => { + const response = await apiClient.get(`${BASE_URL}/folders/`); + return response.data.map(transformFolder); +}; + +export const createFolder = async (name: string): Promise => { + const response = await apiClient.post(`${BASE_URL}/folders/`, { name }); + return transformFolder(response.data); +}; + +export const updateFolder = async (id: number, name: string): Promise => { + const response = await apiClient.patch(`${BASE_URL}/folders/${id}/`, { name }); + return transformFolder(response.data); +}; + +export const deleteFolder = async (id: number): Promise => { + await apiClient.delete(`${BASE_URL}/folders/${id}/`); +}; + +// ============================================================================ +// Emails (Messages) +// ============================================================================ + +export interface PaginatedEmailResponse { + count: number; + next: string | null; + previous: string | null; + results: StaffEmailListItem[]; +} + +export const getEmails = async ( + filters?: StaffEmailFilters, + page: number = 1, + pageSize: number = 50 +): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('page_size', String(pageSize)); + + if (filters?.folderId) params.append('folder', String(filters.folderId)); + if (filters?.emailAddressId) params.append('email_address', String(filters.emailAddressId)); + if (filters?.isRead !== undefined) params.append('is_read', String(filters.isRead)); + if (filters?.isStarred !== undefined) params.append('is_starred', String(filters.isStarred)); + if (filters?.isImportant !== undefined) params.append('is_important', String(filters.isImportant)); + if (filters?.labelId) params.append('label', String(filters.labelId)); + if (filters?.search) params.append('search', filters.search); + if (filters?.fromDate) params.append('from_date', filters.fromDate); + if (filters?.toDate) params.append('to_date', filters.toDate); + + // Debug logging - remove after fixing folder filter issue + console.log('[StaffEmail API] getEmails called with:', { filters, params: params.toString() }); + + const response = await apiClient.get(`${BASE_URL}/messages/?${params.toString()}`); + + // Handle both paginated response {count, results, ...} and legacy array response + const data = response.data; + console.log('[StaffEmail API] Raw response data:', data); + + if (Array.isArray(data)) { + // Legacy format (array of emails) + console.log('[StaffEmail API] Response (legacy array):', { count: data.length }); + return { + count: data.length, + next: null, + previous: null, + results: data.map(transformEmailListItem), + }; + } + + // New paginated format + const result = { + count: data.count ?? 0, + next: data.next ?? null, + previous: data.previous ?? null, + results: (data.results ?? []).map(transformEmailListItem), + }; + + // Debug logging - remove after fixing folder filter issue + console.log('[StaffEmail API] Response:', { count: result.count, resultCount: result.results.length }); + + return result; +}; + +export const getEmail = async (id: number): Promise => { + const response = await apiClient.get(`${BASE_URL}/messages/${id}/`); + return transformEmail(response.data); +}; + +export const getEmailThread = async (threadId: string): Promise => { + const response = await apiClient.get(`${BASE_URL}/messages/`, { + params: { thread_id: threadId }, + }); + return response.data.results.map(transformEmail); +}; + +/** + * Convert string email addresses to the format expected by the backend. + * Backend expects: [{ email: "test@example.com", name: "" }] + * Frontend sends: ["test@example.com"] + */ +function formatEmailAddresses(addresses: string[]): Array<{ email: string; name: string }> { + return addresses.map((addr) => { + // Check if it's already in "Name " format + const match = addr.match(/^(.+?)\s*<(.+?)>$/); + if (match) { + return { name: match[1].trim(), email: match[2].trim() }; + } + return { email: addr.trim(), name: '' }; + }); +} + +export const createDraft = async (data: StaffEmailCreateDraft): Promise => { + const payload = { + email_address: data.emailAddressId, + to_addresses: formatEmailAddresses(data.toAddresses), + cc_addresses: formatEmailAddresses(data.ccAddresses || []), + bcc_addresses: formatEmailAddresses(data.bccAddresses || []), + subject: data.subject, + body_text: data.bodyText || '', + body_html: data.bodyHtml || '', + in_reply_to: data.inReplyTo, + thread_id: data.threadId, + }; + const response = await apiClient.post(`${BASE_URL}/messages/`, payload); + return transformEmail(response.data); +}; + +export const updateDraft = async (id: number, data: Partial): Promise => { + const payload: Record = {}; + if (data.toAddresses !== undefined) payload.to_addresses = formatEmailAddresses(data.toAddresses); + if (data.ccAddresses !== undefined) payload.cc_addresses = formatEmailAddresses(data.ccAddresses); + if (data.bccAddresses !== undefined) payload.bcc_addresses = formatEmailAddresses(data.bccAddresses); + if (data.subject !== undefined) payload.subject = data.subject; + if (data.bodyText !== undefined) payload.body_text = data.bodyText; + if (data.bodyHtml !== undefined) payload.body_html = data.bodyHtml; + + const response = await apiClient.patch(`${BASE_URL}/messages/${id}/`, payload); + return transformEmail(response.data); +}; + +export const deleteDraft = async (id: number): Promise => { + await apiClient.delete(`${BASE_URL}/messages/${id}/`); +}; + +export const sendEmail = async (id: number): Promise => { + const response = await apiClient.post(`${BASE_URL}/messages/${id}/send/`); + return transformEmail(response.data); +}; + +export const replyToEmail = async (id: number, data: StaffEmailReply): Promise => { + const payload = { + body_text: data.bodyText || '', + body_html: data.bodyHtml || '', + reply_all: data.replyAll || false, + }; + const response = await apiClient.post(`${BASE_URL}/messages/${id}/reply/`); + return transformEmail(response.data); +}; + +export const forwardEmail = async (id: number, data: StaffEmailForward): Promise => { + const payload = { + to_addresses: formatEmailAddresses(data.toAddresses), + cc_addresses: formatEmailAddresses(data.ccAddresses || []), + body_text: data.bodyText || '', + body_html: data.bodyHtml || '', + }; + const response = await apiClient.post(`${BASE_URL}/messages/${id}/forward/`, payload); + return transformEmail(response.data); +}; + +export const moveEmails = async (data: StaffEmailMove): Promise => { + await apiClient.post(`${BASE_URL}/messages/move/`, { + email_ids: data.emailIds, + folder_id: data.folderId, + }); +}; + +export const markAsRead = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/mark_read/`); +}; + +export const markAsUnread = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/mark_unread/`); +}; + +export const starEmail = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/star/`); +}; + +export const unstarEmail = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/unstar/`); +}; + +export const archiveEmail = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/archive/`); +}; + +export const trashEmail = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/trash/`); +}; + +export const restoreEmail = async (id: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${id}/restore/`); +}; + +export const permanentlyDeleteEmail = async (id: number): Promise => { + await apiClient.delete(`${BASE_URL}/messages/${id}/`); +}; + +export const bulkAction = async (data: StaffEmailBulkAction): Promise => { + await apiClient.post(`${BASE_URL}/messages/bulk_action/`, { + email_ids: data.emailIds, + action: data.action, + }); +}; + +// ============================================================================ +// Labels +// ============================================================================ + +export const getLabels = async (): Promise => { + const response = await apiClient.get(`${BASE_URL}/labels/`); + return response.data.map(transformLabel); +}; + +export const createLabel = async (name: string, color: string): Promise => { + const response = await apiClient.post(`${BASE_URL}/labels/`, { name, color }); + return transformLabel(response.data); +}; + +export const updateLabel = async (id: number, data: { name?: string; color?: string }): Promise => { + const response = await apiClient.patch(`${BASE_URL}/labels/${id}/`, data); + return transformLabel(response.data); +}; + +export const deleteLabel = async (id: number): Promise => { + await apiClient.delete(`${BASE_URL}/labels/${id}/`); +}; + +export const addLabelToEmail = async (emailId: number, labelId: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${emailId}/add_label/`, { label_id: labelId }); +}; + +export const removeLabelFromEmail = async (emailId: number, labelId: number): Promise => { + await apiClient.post(`${BASE_URL}/messages/${emailId}/remove_label/`, { label_id: labelId }); +}; + +// ============================================================================ +// Contacts +// ============================================================================ + +export const searchContacts = async (query: string): Promise => { + const response = await apiClient.get(`${BASE_URL}/contacts/`, { + params: { search: query }, + }); + return response.data.map(transformContact); +}; + +// ============================================================================ +// Attachments +// ============================================================================ + +export const uploadAttachment = async (file: File, emailId?: number): Promise => { + const formData = new FormData(); + formData.append('file', file); + if (emailId) { + formData.append('email_id', String(emailId)); + } + const response = await apiClient.post(`${BASE_URL}/attachments/`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return transformAttachment(response.data); +}; + +export const deleteAttachment = async (id: number): Promise => { + await apiClient.delete(`${BASE_URL}/attachments/${id}/`); +}; + +// ============================================================================ +// Sync +// ============================================================================ + +export const syncEmails = async (): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`${BASE_URL}/messages/sync/`); + return response.data; +}; + +export interface FullSyncTask { + email_address: string; + task_id: string; +} + +export interface FullSyncResponse { + status: string; + tasks: FullSyncTask[]; +} + +export const fullSyncEmails = async (): Promise => { + const response = await apiClient.post(`${BASE_URL}/messages/full_sync/`); + return response.data; +}; + +// ============================================================================ +// User's Email Addresses +// ============================================================================ + +export interface UserEmailAddress { + id: number; + email_address: string; + display_name: string; + color: string; + is_default: boolean; + last_check_at: string | null; + emails_processed_count: number; +} + +export const getUserEmailAddresses = async (): Promise => { + const response = await apiClient.get(`${BASE_URL}/messages/email_addresses/`); + return response.data; +}; + +// ============================================================================ +// Transform Functions (snake_case -> camelCase) +// ============================================================================ + +function transformFolder(data: any): StaffEmailFolder { + return { + id: data.id, + owner: data.owner, + name: data.name, + folderType: data.folder_type, + emailCount: data.email_count || 0, + unreadCount: data.unread_count || 0, + createdAt: data.created_at, + updatedAt: data.updated_at, + }; +} + +function transformEmailListItem(data: any): StaffEmailListItem { + return { + id: data.id, + folder: data.folder, + fromAddress: data.from_address, + fromName: data.from_name || '', + toAddresses: data.to_addresses || [], + subject: data.subject || '(No Subject)', + snippet: data.snippet || '', + status: data.status, + isRead: data.is_read, + isStarred: data.is_starred, + isImportant: data.is_important, + hasAttachments: data.has_attachments || false, + attachmentCount: data.attachment_count || 0, + threadId: data.thread_id, + emailDate: data.email_date, + createdAt: data.created_at, + labels: (data.labels || []).map(transformLabel), + }; +} + +function transformEmail(data: any): StaffEmail { + return { + ...transformEmailListItem(data), + owner: data.owner, + emailAddress: data.email_address, + messageId: data.message_id || '', + inReplyTo: data.in_reply_to, + references: data.references || '', + ccAddresses: data.cc_addresses || [], + bccAddresses: data.bcc_addresses || [], + bodyText: data.body_text || '', + bodyHtml: data.body_html || '', + isAnswered: data.is_answered || false, + isPermanentlyDeleted: data.is_permanently_deleted || false, + deletedAt: data.deleted_at, + attachments: (data.attachments || []).map(transformAttachment), + updatedAt: data.updated_at, + }; +} + +function transformLabel(data: any): StaffEmailLabel { + return { + id: data.id, + owner: data.owner, + name: data.name, + color: data.color || '#3b82f6', + createdAt: data.created_at, + }; +} + +function transformAttachment(data: any): StaffEmailAttachment { + return { + id: data.id, + filename: data.filename, + contentType: data.content_type, + size: data.size, + url: data.url || data.file_url || '', + createdAt: data.created_at, + }; +} + +function transformContact(data: any): EmailContactSuggestion { + return { + id: data.id, + owner: data.owner, + email: data.email, + name: data.name || '', + useCount: data.use_count || 0, + lastUsedAt: data.last_used_at, + }; +} diff --git a/frontend/src/components/FloatingHelpButton.tsx b/frontend/src/components/FloatingHelpButton.tsx deleted file mode 100644 index a47e72ba..00000000 --- a/frontend/src/components/FloatingHelpButton.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * FloatingHelpButton Component - * - * A floating help button fixed in the top-right corner of the screen. - * Automatically determines the help path based on the current route. - */ - -import React from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import { HelpCircle } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -// Map route suffixes to their help page suffixes -// These get prefixed appropriately based on context (tenant dashboard or public) -const routeToHelpSuffix: Record = { - '/': 'dashboard', - '/dashboard': 'dashboard', - '/scheduler': 'scheduler', - '/my-schedule': 'scheduler', - '/tasks': 'tasks', - '/customers': 'customers', - '/services': 'services', - '/resources': 'resources', - '/locations': 'locations', - '/staff': 'staff', - '/time-blocks': 'time-blocks', - '/my-availability': 'time-blocks', - '/messages': 'messages', - '/tickets': 'ticketing', - '/payments': 'payments', - '/contracts': 'contracts', - '/contracts/templates': 'contracts', - '/automations': 'automations', - '/automations/marketplace': 'automations', - '/automations/my-automations': 'automations', - '/automations/create': 'automations/docs', - '/site-editor': 'site-builder', - '/gallery': 'site-builder', - '/settings': 'settings/general', - '/settings/general': 'settings/general', - '/settings/resource-types': 'settings/resource-types', - '/settings/booking': 'settings/booking', - '/settings/appearance': 'settings/appearance', - '/settings/branding': 'settings/appearance', - '/settings/business-hours': 'settings/business-hours', - '/settings/email': 'settings/email', - '/settings/email-templates': 'settings/email-templates', - '/settings/embed-widget': 'settings/embed-widget', - '/settings/staff-roles': 'settings/staff-roles', - '/settings/sms-calling': 'settings/communication', - '/settings/domains': 'settings/domains', - '/settings/api': 'settings/api', - '/settings/auth': 'settings/auth', - '/settings/billing': 'settings/billing', - '/settings/quota': 'settings/quota', -}; - -const FloatingHelpButton: React.FC = () => { - const { t } = useTranslation(); - const location = useLocation(); - - // Check if we're on a tenant dashboard route - const isOnDashboard = location.pathname.startsWith('/dashboard'); - - // Get the help path for the current route - const getHelpPath = (): string => { - // Determine the base help path based on context - const helpBase = isOnDashboard ? '/dashboard/help' : '/help'; - - // Get the route to look up (strip /dashboard prefix if present) - const lookupPath = isOnDashboard - ? location.pathname.replace(/^\/dashboard/, '') || '/' - : location.pathname; - - // Exact match first - if (routeToHelpSuffix[lookupPath]) { - return `${helpBase}/${routeToHelpSuffix[lookupPath]}`; - } - - // Try matching with a prefix (for dynamic routes like /customers/:id) - const pathSegments = lookupPath.split('/').filter(Boolean); - if (pathSegments.length > 0) { - // Try progressively shorter paths - for (let i = pathSegments.length; i > 0; i--) { - const testPath = '/' + pathSegments.slice(0, i).join('/'); - if (routeToHelpSuffix[testPath]) { - return `${helpBase}/${routeToHelpSuffix[testPath]}`; - } - } - } - - // Default to the main help page - return helpBase; - }; - - const helpPath = getHelpPath(); - - // Don't show on help pages themselves - if (location.pathname.includes('/help')) { - return null; - } - - return ( - - - - ); -}; - -export default FloatingHelpButton; diff --git a/frontend/src/components/HelpButton.tsx b/frontend/src/components/HelpButton.tsx index 4cba2482..a4becd60 100644 --- a/frontend/src/components/HelpButton.tsx +++ b/frontend/src/components/HelpButton.tsx @@ -1,31 +1,113 @@ /** * HelpButton Component * - * A contextual help button that appears at the top-right of pages - * and links to the relevant help documentation. + * A help button for the top bar that navigates to context-aware help pages. + * Automatically determines the help path based on the current route. */ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { HelpCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -interface HelpButtonProps { - helpPath: string; - className?: string; -} +// Map route suffixes to their help page suffixes +// These get prefixed appropriately based on context (tenant dashboard or public) +const routeToHelpSuffix: Record = { + '/': 'dashboard', + '/dashboard': 'dashboard', + '/scheduler': 'scheduler', + '/my-schedule': 'scheduler', + '/tasks': 'tasks', + '/customers': 'customers', + '/services': 'services', + '/resources': 'resources', + '/locations': 'locations', + '/staff': 'staff', + '/time-blocks': 'time-blocks', + '/my-availability': 'time-blocks', + '/messages': 'messages', + '/tickets': 'ticketing', + '/payments': 'payments', + '/contracts': 'contracts', + '/contracts/templates': 'contracts', + '/automations': 'automations', + '/automations/marketplace': 'automations', + '/automations/my-automations': 'automations', + '/automations/create': 'automations/docs', + '/site-editor': 'site-builder', + '/gallery': 'site-builder', + '/settings': 'settings/general', + '/settings/general': 'settings/general', + '/settings/resource-types': 'settings/resource-types', + '/settings/booking': 'settings/booking', + '/settings/appearance': 'settings/appearance', + '/settings/branding': 'settings/appearance', + '/settings/business-hours': 'settings/business-hours', + '/settings/email': 'settings/email', + '/settings/email-templates': 'settings/email-templates', + '/settings/embed-widget': 'settings/embed-widget', + '/settings/staff-roles': 'settings/staff-roles', + '/settings/sms-calling': 'settings/communication', + '/settings/domains': 'settings/domains', + '/settings/api': 'settings/api', + '/settings/auth': 'settings/auth', + '/settings/billing': 'settings/billing', + '/settings/quota': 'settings/quota', +}; -const HelpButton: React.FC = ({ helpPath, className = '' }) => { +const HelpButton: React.FC = () => { const { t } = useTranslation(); + const location = useLocation(); + + // Check if we're on a tenant dashboard route + const isOnDashboard = location.pathname.startsWith('/dashboard'); + + // Get the help path for the current route + const getHelpPath = (): string => { + // Determine the base help path based on context + const helpBase = isOnDashboard ? '/dashboard/help' : '/help'; + + // Get the route to look up (strip /dashboard prefix if present) + const lookupPath = isOnDashboard + ? location.pathname.replace(/^\/dashboard/, '') || '/' + : location.pathname; + + // Exact match first + if (routeToHelpSuffix[lookupPath]) { + return `${helpBase}/${routeToHelpSuffix[lookupPath]}`; + } + + // Try matching with a prefix (for dynamic routes like /customers/:id) + const pathSegments = lookupPath.split('/').filter(Boolean); + if (pathSegments.length > 0) { + // Try progressively shorter paths + for (let i = pathSegments.length; i > 0; i--) { + const testPath = '/' + pathSegments.slice(0, i).join('/'); + if (routeToHelpSuffix[testPath]) { + return `${helpBase}/${routeToHelpSuffix[testPath]}`; + } + } + } + + // Default to the main help page + return helpBase; + }; + + const helpPath = getHelpPath(); + + // Don't show on help pages themselves + if (location.pathname.includes('/help')) { + return null; + } return ( - - {t('common.help', 'Help')} + ); }; diff --git a/frontend/src/components/PlatformEmailAddressManager.tsx b/frontend/src/components/PlatformEmailAddressManager.tsx index 3d10a280..a19453ee 100644 --- a/frontend/src/components/PlatformEmailAddressManager.tsx +++ b/frontend/src/components/PlatformEmailAddressManager.tsx @@ -59,6 +59,7 @@ interface EmailAddressFormData { domain: string; color: string; password: string; + routing_mode: 'PLATFORM' | 'STAFF'; is_active: boolean; is_default: boolean; } @@ -92,6 +93,7 @@ const PlatformEmailAddressManager: React.FC = () => { domain: 'smoothschedule.com', color: '#3b82f6', password: '', + routing_mode: 'PLATFORM', is_active: true, is_default: false, }); @@ -120,6 +122,7 @@ const PlatformEmailAddressManager: React.FC = () => { domain: 'smoothschedule.com', color: '#3b82f6', password: '', + routing_mode: 'PLATFORM', is_active: true, is_default: false, }); @@ -137,6 +140,7 @@ const PlatformEmailAddressManager: React.FC = () => { domain: address.domain, color: address.color, password: '', + routing_mode: address.routing_mode || 'PLATFORM', is_active: address.is_active, is_default: address.is_default, }); @@ -188,6 +192,7 @@ const PlatformEmailAddressManager: React.FC = () => { sender_name: formData.sender_name, assigned_user_id: formData.assigned_user_id, color: formData.color, + routing_mode: formData.routing_mode, is_active: formData.is_active, is_default: formData.is_default, }; @@ -210,6 +215,7 @@ const PlatformEmailAddressManager: React.FC = () => { domain: formData.domain, color: formData.color, password: formData.password, + routing_mode: formData.routing_mode, is_active: formData.is_active, is_default: formData.is_default, }); @@ -607,6 +613,27 @@ const PlatformEmailAddressManager: React.FC = () => {

+ {/* Routing Mode */} +
+ + +

+ Platform: Emails become support tickets. Staff: Emails go to the assigned user's inbox. +

+
+ {/* Email Address (only show for new addresses) */} {!editingAddress && (
diff --git a/frontend/src/components/PlatformSidebar.tsx b/frontend/src/components/PlatformSidebar.tsx index 35b23538..92eb1be2 100644 --- a/frontend/src/components/PlatformSidebar.tsx +++ b/frontend/src/components/PlatformSidebar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react'; +import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react'; import { User } from '../types'; import SmoothScheduleLogo from './SmoothScheduleLogo'; @@ -16,7 +16,9 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to const location = useLocation(); const getNavClass = (path: string) => { - const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path)); + // Exact match or starts with path followed by / + const isActive = location.pathname === path || + (path !== '/' && (location.pathname.startsWith(path + '/') || location.pathname === path)); const baseClasses = `flex items-center gap-3 py-2 text-sm font-medium rounded-md transition-colors`; const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3'; const activeClasses = 'bg-gray-700 text-white'; @@ -67,6 +69,10 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to {!isCollapsed && Email Addresses} + + + {!isCollapsed && My Inbox} + {isSuperuser && ( <> diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index b0bbf7e9..7ac0105a 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -6,6 +6,7 @@ import UserProfileDropdown from './UserProfileDropdown'; import LanguageSelector from './LanguageSelector'; import NotificationDropdown from './NotificationDropdown'; import SandboxToggle from './SandboxToggle'; +import HelpButton from './HelpButton'; import { useSandbox } from '../contexts/SandboxContext'; interface TopBarProps { @@ -62,6 +63,8 @@ const TopBar: React.FC = ({ user, isDarkMode, toggleTheme, onMenuCl + +
diff --git a/frontend/src/components/__tests__/FloatingHelpButton.test.tsx b/frontend/src/components/__tests__/FloatingHelpButton.test.tsx deleted file mode 100644 index 958d0e8e..00000000 --- a/frontend/src/components/__tests__/FloatingHelpButton.test.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import FloatingHelpButton from '../FloatingHelpButton'; - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValue?: string) => defaultValue || key, - }), -})); - -describe('FloatingHelpButton', () => { - const renderWithRouter = (initialPath: string) => { - return render( - - - - ); - }; - - describe('tenant dashboard routes (prefixed with /dashboard)', () => { - it('renders help link on tenant dashboard', () => { - renderWithRouter('/dashboard'); - const link = screen.getByRole('link'); - expect(link).toBeInTheDocument(); - }); - - it('links to /dashboard/help/dashboard for /dashboard', () => { - renderWithRouter('/dashboard'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/dashboard'); - }); - - it('links to /dashboard/help/scheduler for /dashboard/scheduler', () => { - renderWithRouter('/dashboard/scheduler'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/scheduler'); - }); - - it('links to /dashboard/help/services for /dashboard/services', () => { - renderWithRouter('/dashboard/services'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/services'); - }); - - it('links to /dashboard/help/resources for /dashboard/resources', () => { - renderWithRouter('/dashboard/resources'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/resources'); - }); - - it('links to /dashboard/help/settings/general for /dashboard/settings/general', () => { - renderWithRouter('/dashboard/settings/general'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/general'); - }); - - it('links to /dashboard/help/customers for /dashboard/customers/123', () => { - renderWithRouter('/dashboard/customers/123'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/customers'); - }); - - it('returns null on /dashboard/help pages', () => { - const { container } = renderWithRouter('/dashboard/help/dashboard'); - expect(container.firstChild).toBeNull(); - }); - - it('links to /dashboard/help for unknown dashboard routes', () => { - renderWithRouter('/dashboard/unknown-route'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help'); - }); - - it('links to /dashboard/help/site-builder for /dashboard/site-editor', () => { - renderWithRouter('/dashboard/site-editor'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/site-builder'); - }); - - it('links to /dashboard/help/site-builder for /dashboard/gallery', () => { - renderWithRouter('/dashboard/gallery'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/site-builder'); - }); - - it('links to /dashboard/help/locations for /dashboard/locations', () => { - renderWithRouter('/dashboard/locations'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/locations'); - }); - - it('links to /dashboard/help/settings/business-hours for /dashboard/settings/business-hours', () => { - renderWithRouter('/dashboard/settings/business-hours'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/business-hours'); - }); - - it('links to /dashboard/help/settings/email-templates for /dashboard/settings/email-templates', () => { - renderWithRouter('/dashboard/settings/email-templates'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/email-templates'); - }); - - it('links to /dashboard/help/settings/embed-widget for /dashboard/settings/embed-widget', () => { - renderWithRouter('/dashboard/settings/embed-widget'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/embed-widget'); - }); - - it('links to /dashboard/help/settings/staff-roles for /dashboard/settings/staff-roles', () => { - renderWithRouter('/dashboard/settings/staff-roles'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/staff-roles'); - }); - - it('links to /dashboard/help/settings/communication for /dashboard/settings/sms-calling', () => { - renderWithRouter('/dashboard/settings/sms-calling'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/dashboard/help/settings/communication'); - }); - }); - - describe('non-dashboard routes (public/platform)', () => { - it('links to /help/scheduler for /scheduler', () => { - renderWithRouter('/scheduler'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/scheduler'); - }); - - it('links to /help/services for /services', () => { - renderWithRouter('/services'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/services'); - }); - - it('links to /help/resources for /resources', () => { - renderWithRouter('/resources'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/resources'); - }); - - it('links to /help/settings/general for /settings/general', () => { - renderWithRouter('/settings/general'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/general'); - }); - - it('links to /help/locations for /locations', () => { - renderWithRouter('/locations'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/locations'); - }); - - it('links to /help/settings/business-hours for /settings/business-hours', () => { - renderWithRouter('/settings/business-hours'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/business-hours'); - }); - - it('links to /help/settings/email-templates for /settings/email-templates', () => { - renderWithRouter('/settings/email-templates'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/email-templates'); - }); - - it('links to /help/settings/embed-widget for /settings/embed-widget', () => { - renderWithRouter('/settings/embed-widget'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/embed-widget'); - }); - - it('links to /help/settings/staff-roles for /settings/staff-roles', () => { - renderWithRouter('/settings/staff-roles'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/staff-roles'); - }); - - it('links to /help/settings/communication for /settings/sms-calling', () => { - renderWithRouter('/settings/sms-calling'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/settings/communication'); - }); - - it('returns null on /help pages', () => { - const { container } = renderWithRouter('/help/dashboard'); - expect(container.firstChild).toBeNull(); - }); - - it('links to /help for unknown routes', () => { - renderWithRouter('/unknown-route'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help'); - }); - - it('handles dynamic routes by matching prefix', () => { - renderWithRouter('/customers/123'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/customers'); - }); - }); - - describe('accessibility', () => { - it('has aria-label', () => { - renderWithRouter('/dashboard'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('aria-label', 'Help'); - }); - - it('has title attribute', () => { - renderWithRouter('/dashboard'); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('title', 'Help'); - }); - }); -}); diff --git a/frontend/src/components/__tests__/HelpButton.test.tsx b/frontend/src/components/__tests__/HelpButton.test.tsx index 1b35a95d..bf313d93 100644 --- a/frontend/src/components/__tests__/HelpButton.test.tsx +++ b/frontend/src/components/__tests__/HelpButton.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import HelpButton from '../HelpButton'; // Mock react-i18next @@ -11,47 +11,207 @@ vi.mock('react-i18next', () => ({ })); describe('HelpButton', () => { - const renderHelpButton = (props: { helpPath: string; className?: string }) => { + const renderWithRouter = (initialPath: string) => { return render( - - - + + + ); }; - it('renders help link', () => { - renderHelpButton({ helpPath: '/help/dashboard' }); - const link = screen.getByRole('link'); - expect(link).toBeInTheDocument(); + describe('tenant dashboard routes (prefixed with /dashboard)', () => { + it('renders help link on tenant dashboard', () => { + renderWithRouter('/dashboard'); + const link = screen.getByRole('link'); + expect(link).toBeInTheDocument(); + }); + + it('links to /dashboard/help/dashboard for /dashboard', () => { + renderWithRouter('/dashboard'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/dashboard'); + }); + + it('links to /dashboard/help/scheduler for /dashboard/scheduler', () => { + renderWithRouter('/dashboard/scheduler'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/scheduler'); + }); + + it('links to /dashboard/help/services for /dashboard/services', () => { + renderWithRouter('/dashboard/services'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/services'); + }); + + it('links to /dashboard/help/resources for /dashboard/resources', () => { + renderWithRouter('/dashboard/resources'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/resources'); + }); + + it('links to /dashboard/help/settings/general for /dashboard/settings/general', () => { + renderWithRouter('/dashboard/settings/general'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/general'); + }); + + it('links to /dashboard/help/customers for /dashboard/customers/123', () => { + renderWithRouter('/dashboard/customers/123'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/customers'); + }); + + it('returns null on /dashboard/help pages', () => { + const { container } = renderWithRouter('/dashboard/help/dashboard'); + expect(container.firstChild).toBeNull(); + }); + + it('links to /dashboard/help for unknown dashboard routes', () => { + renderWithRouter('/dashboard/unknown-route'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help'); + }); + + it('links to /dashboard/help/site-builder for /dashboard/site-editor', () => { + renderWithRouter('/dashboard/site-editor'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/site-builder'); + }); + + it('links to /dashboard/help/site-builder for /dashboard/gallery', () => { + renderWithRouter('/dashboard/gallery'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/site-builder'); + }); + + it('links to /dashboard/help/locations for /dashboard/locations', () => { + renderWithRouter('/dashboard/locations'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/locations'); + }); + + it('links to /dashboard/help/settings/business-hours for /dashboard/settings/business-hours', () => { + renderWithRouter('/dashboard/settings/business-hours'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/business-hours'); + }); + + it('links to /dashboard/help/settings/email-templates for /dashboard/settings/email-templates', () => { + renderWithRouter('/dashboard/settings/email-templates'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/email-templates'); + }); + + it('links to /dashboard/help/settings/embed-widget for /dashboard/settings/embed-widget', () => { + renderWithRouter('/dashboard/settings/embed-widget'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/embed-widget'); + }); + + it('links to /dashboard/help/settings/staff-roles for /dashboard/settings/staff-roles', () => { + renderWithRouter('/dashboard/settings/staff-roles'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/staff-roles'); + }); + + it('links to /dashboard/help/settings/communication for /dashboard/settings/sms-calling', () => { + renderWithRouter('/dashboard/settings/sms-calling'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/dashboard/help/settings/communication'); + }); }); - it('has correct href', () => { - renderHelpButton({ helpPath: '/help/dashboard' }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/help/dashboard'); + describe('non-dashboard routes (public/platform)', () => { + it('links to /help/scheduler for /scheduler', () => { + renderWithRouter('/scheduler'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/scheduler'); + }); + + it('links to /help/services for /services', () => { + renderWithRouter('/services'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/services'); + }); + + it('links to /help/resources for /resources', () => { + renderWithRouter('/resources'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/resources'); + }); + + it('links to /help/settings/general for /settings/general', () => { + renderWithRouter('/settings/general'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/general'); + }); + + it('links to /help/locations for /locations', () => { + renderWithRouter('/locations'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/locations'); + }); + + it('links to /help/settings/business-hours for /settings/business-hours', () => { + renderWithRouter('/settings/business-hours'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/business-hours'); + }); + + it('links to /help/settings/email-templates for /settings/email-templates', () => { + renderWithRouter('/settings/email-templates'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/email-templates'); + }); + + it('links to /help/settings/embed-widget for /settings/embed-widget', () => { + renderWithRouter('/settings/embed-widget'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/embed-widget'); + }); + + it('links to /help/settings/staff-roles for /settings/staff-roles', () => { + renderWithRouter('/settings/staff-roles'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/staff-roles'); + }); + + it('links to /help/settings/communication for /settings/sms-calling', () => { + renderWithRouter('/settings/sms-calling'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/settings/communication'); + }); + + it('returns null on /help pages', () => { + const { container } = renderWithRouter('/help/dashboard'); + expect(container.firstChild).toBeNull(); + }); + + it('links to /help for unknown routes', () => { + renderWithRouter('/unknown-route'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help'); + }); + + it('handles dynamic routes by matching prefix', () => { + renderWithRouter('/customers/123'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/help/customers'); + }); }); - it('renders help text', () => { - renderHelpButton({ helpPath: '/help/test' }); - expect(screen.getByText('Help')).toBeInTheDocument(); - }); + describe('accessibility', () => { + it('has aria-label', () => { + renderWithRouter('/dashboard'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('aria-label', 'Help'); + }); - it('has title attribute', () => { - renderHelpButton({ helpPath: '/help/test' }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('title', 'Help'); - }); - - it('applies custom className', () => { - renderHelpButton({ helpPath: '/help/test', className: 'custom-class' }); - const link = screen.getByRole('link'); - expect(link).toHaveClass('custom-class'); - }); - - it('has default styles', () => { - renderHelpButton({ helpPath: '/help/test' }); - const link = screen.getByRole('link'); - expect(link).toHaveClass('inline-flex'); - expect(link).toHaveClass('items-center'); + it('has title attribute', () => { + renderWithRouter('/dashboard'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('title', 'Help'); + }); }); }); diff --git a/frontend/src/components/email/EmailComposer.tsx b/frontend/src/components/email/EmailComposer.tsx new file mode 100644 index 00000000..43d0adda --- /dev/null +++ b/frontend/src/components/email/EmailComposer.tsx @@ -0,0 +1,420 @@ +/** + * Email Composer Component + * + * Compose, reply, and forward emails with rich text editing. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + X, + Send, + Paperclip, + Trash2, + Minimize2, + Maximize2, + Bold, + Italic, + Underline, + List, + ListOrdered, + Link, + Loader2, +} from 'lucide-react'; +import { StaffEmail, StaffEmailCreateDraft } from '../../types'; +import { + useCreateDraft, + useUpdateDraft, + useSendEmail, + useUploadAttachment, + useContactSearch, + useUserEmailAddresses, +} from '../../hooks/useStaffEmail'; +import toast from 'react-hot-toast'; + +interface EmailComposerProps { + replyTo?: StaffEmail | null; + forwardFrom?: StaffEmail | null; + onClose: () => void; + onSent: () => void; +} + +const EmailComposer: React.FC = ({ + replyTo, + forwardFrom, + onClose, + onSent, +}) => { + const { t } = useTranslation(); + const textareaRef = useRef(null); + + // Get available email addresses for sending (only those assigned to current user) + const { data: userEmailAddresses = [] } = useUserEmailAddresses(); + + // Form state + const [fromAddressId, setFromAddressId] = useState(null); + const [to, setTo] = useState(''); + const [cc, setCc] = useState(''); + const [bcc, setBcc] = useState(''); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [showCc, setShowCc] = useState(false); + const [showBcc, setShowBcc] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + const [draftId, setDraftId] = useState(null); + + // Contact search + const [toQuery, setToQuery] = useState(''); + const { data: contactSuggestions = [] } = useContactSearch(toQuery); + + // Mutations + const createDraft = useCreateDraft(); + const updateDraft = useUpdateDraft(); + const sendEmail = useSendEmail(); + const uploadAttachment = useUploadAttachment(); + + // Initialize form for reply/forward + useEffect(() => { + if (replyTo) { + // Reply mode + setTo(replyTo.fromAddress); + setSubject(replyTo.subject.startsWith('Re:') ? replyTo.subject : `Re: ${replyTo.subject}`); + setBody(`\n\n---\nOn ${new Date(replyTo.emailDate).toLocaleString()}, ${replyTo.fromName || replyTo.fromAddress} wrote:\n\n${replyTo.bodyText}`); + } else if (forwardFrom) { + // Forward mode + setSubject(forwardFrom.subject.startsWith('Fwd:') ? forwardFrom.subject : `Fwd: ${forwardFrom.subject}`); + setBody(`\n\n---\nForwarded message:\nFrom: ${forwardFrom.fromName || forwardFrom.fromAddress} <${forwardFrom.fromAddress}>\nDate: ${new Date(forwardFrom.emailDate).toLocaleString()}\nSubject: ${forwardFrom.subject}\nTo: ${forwardFrom.toAddresses.join(', ')}\n\n${forwardFrom.bodyText}`); + } + }, [replyTo, forwardFrom]); + + // Set default from address + useEffect(() => { + if (!fromAddressId && userEmailAddresses.length > 0) { + setFromAddressId(userEmailAddresses[0].id); + } + }, [userEmailAddresses, fromAddressId]); + + const parseAddresses = (input: string): string[] => { + return input + .split(/[,;]/) + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0); + }; + + const handleSend = async () => { + if (!fromAddressId) { + toast.error('Please select a From address'); + return; + } + + const toAddresses = parseAddresses(to); + if (toAddresses.length === 0) { + toast.error('Please enter at least one recipient'); + return; + } + + try { + // Create or update draft first + let emailId = draftId; + + const draftData: StaffEmailCreateDraft = { + emailAddressId: fromAddressId, + toAddresses, + ccAddresses: parseAddresses(cc), + bccAddresses: parseAddresses(bcc), + subject: subject || '(No Subject)', + bodyText: body, + bodyHtml: `
${body.replace(//g, '>')}
`, + inReplyTo: replyTo?.id, + threadId: replyTo?.threadId || undefined, + }; + + if (emailId) { + await updateDraft.mutateAsync({ id: emailId, data: draftData }); + } else { + const draft = await createDraft.mutateAsync(draftData); + emailId = draft.id; + setDraftId(emailId); + } + + // Send the email + await sendEmail.mutateAsync(emailId); + toast.success('Email sent'); + onSent(); + } catch (error: any) { + toast.error(error.response?.data?.error || 'Failed to send email'); + } + }; + + const handleSaveDraft = async () => { + if (!fromAddressId) { + toast.error('Please select a From address'); + return; + } + + try { + const draftData: StaffEmailCreateDraft = { + emailAddressId: fromAddressId, + toAddresses: parseAddresses(to), + ccAddresses: parseAddresses(cc), + bccAddresses: parseAddresses(bcc), + subject: subject || '(No Subject)', + bodyText: body, + bodyHtml: `
${body.replace(//g, '>')}
`, + inReplyTo: replyTo?.id, + threadId: replyTo?.threadId || undefined, + }; + + if (draftId) { + await updateDraft.mutateAsync({ id: draftId, data: draftData }); + } else { + const draft = await createDraft.mutateAsync(draftData); + setDraftId(draft.id); + } + toast.success('Draft saved'); + } catch (error: any) { + toast.error(error.response?.data?.error || 'Failed to save draft'); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + // TODO: Implement attachment upload when draft is created + toast.error('Attachments not yet implemented'); + }; + + const isSending = createDraft.isPending || updateDraft.isPending || sendEmail.isPending; + + if (isMinimized) { + return ( +
+
setIsMinimized(false)} + > + + {subject || 'New Message'} + +
+ + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + {replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'} + +
+ + +
+
+ + {/* Form */} +
+ {/* From */} +
+ + +
+ + {/* To */} +
+ + setTo(e.target.value)} + placeholder="recipient@example.com" + className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0" + /> +
+ {!showCc && ( + + )} + {!showBcc && ( + + )} +
+
+ + {/* Cc */} + {showCc && ( +
+ + setCc(e.target.value)} + placeholder="cc@example.com" + className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0" + /> +
+ )} + + {/* Bcc */} + {showBcc && ( +
+ + setBcc(e.target.value)} + placeholder="bcc@example.com" + className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0" + /> +
+ )} + + {/* Subject */} +
+ + setSubject(e.target.value)} + placeholder="Email subject" + className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0" + /> +
+ + {/* Body */} +
+