diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index caf6be6..dfc66f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import Resources from './pages/Resources'; import Services from './pages/Services'; import Staff from './pages/Staff'; import CustomerDashboard from './pages/customer/CustomerDashboard'; +import CustomerSupport from './pages/customer/CustomerSupport'; import ResourceDashboard from './pages/resource/ResourceDashboard'; import BookingPage from './pages/customer/BookingPage'; import TrialExpired from './pages/TrialExpired'; @@ -47,7 +48,7 @@ import Upgrade from './pages/Upgrade'; // Import platform pages import PlatformDashboard from './pages/platform/PlatformDashboard'; import PlatformBusinesses from './pages/platform/PlatformBusinesses'; -import PlatformSupport from './pages/platform/PlatformSupport'; +import PlatformSupportPage from './pages/platform/PlatformSupport'; import PlatformUsers from './pages/platform/PlatformUsers'; import PlatformSettings from './pages/platform/PlatformSettings'; import ProfileSettings from './pages/ProfileSettings'; @@ -56,6 +57,10 @@ import EmailVerificationRequired from './pages/EmailVerificationRequired'; import AcceptInvitePage from './pages/AcceptInvitePage'; import TenantOnboardPage from './pages/TenantOnboardPage'; import Tickets from './pages/Tickets'; // Import Tickets page +import HelpGuide from './pages/HelpGuide'; // Import Platform Guide page +import HelpTicketing from './pages/HelpTicketing'; // Import Help page for ticketing +import HelpApiDocs from './pages/HelpApiDocs'; // Import API documentation page +import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule) import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications const queryClient = new QueryClient({ @@ -321,7 +326,10 @@ const AppContent: React.FC = () => { } /> )} - } /> + } /> + } /> + } /> + } /> {user.role === 'superuser' && ( } /> )} @@ -346,19 +354,47 @@ const AppContent: React.FC = () => { // Customer users if (user.role === 'customer') { + // Wait for business data to load + if (businessLoading) { + return ; + } + + // Handle business not found for customers + if (!business) { + return ( +
+
+

Business Not Found

+

+ Unable to load business data. Please try again. +

+ +
+
+ ); + } + return ( } > } /> } /> } /> + } /> } /> } /> } /> @@ -470,6 +506,10 @@ const AppContent: React.FC = () => { /> } /> } /> + } /> + } /> + } /> + } /> { + try { + return localStorage.getItem('sandbox_mode') === 'true'; + } catch { + return false; + } +}; + +// Request interceptor - add auth token, business subdomain, and sandbox mode apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Add business subdomain header if on business site @@ -32,6 +44,12 @@ apiClient.interceptors.request.use( config.headers['Authorization'] = `Token ${token}`; } + // Add sandbox mode header if in test mode + const isSandbox = getSandboxMode(); + if (isSandbox) { + config.headers['X-Sandbox-Mode'] = 'true'; + } + return config; }, (error) => { diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..ad7c7db --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,64 @@ +import apiClient from './client'; + +export interface Notification { + id: number; + verb: string; + read: boolean; + timestamp: string; + data: Record; + actor_type: string | null; + actor_display: string | null; + target_type: string | null; + target_display: string | null; + target_url: string | null; +} + +export interface UnreadCountResponse { + count: number; +} + +/** + * Get all notifications for the current user + */ +export const getNotifications = async (params?: { read?: boolean; limit?: number }): Promise => { + const queryParams = new URLSearchParams(); + if (params?.read !== undefined) { + queryParams.append('read', String(params.read)); + } + if (params?.limit !== undefined) { + queryParams.append('limit', String(params.limit)); + } + const query = queryParams.toString(); + const url = query ? `/api/notifications/?${query}` : '/api/notifications/'; + const response = await apiClient.get(url); + return response.data; +}; + +/** + * Get count of unread notifications + */ +export const getUnreadCount = async (): Promise => { + const response = await apiClient.get('/api/notifications/unread_count/'); + return response.data.count; +}; + +/** + * Mark a single notification as read + */ +export const markNotificationRead = async (id: number): Promise => { + await apiClient.post(`/api/notifications/${id}/mark_read/`); +}; + +/** + * Mark all notifications as read + */ +export const markAllNotificationsRead = async (): Promise => { + await apiClient.post('/api/notifications/mark_all_read/'); +}; + +/** + * Delete all read notifications + */ +export const clearAllNotifications = async (): Promise => { + await apiClient.delete('/api/notifications/clear_all/'); +}; diff --git a/frontend/src/api/sandbox.ts b/frontend/src/api/sandbox.ts new file mode 100644 index 0000000..1eab83b --- /dev/null +++ b/frontend/src/api/sandbox.ts @@ -0,0 +1,48 @@ +/** + * Sandbox Mode API + * Manage live/test mode switching for isolated test data + */ + +import apiClient from './client'; + +export interface SandboxStatus { + sandbox_mode: boolean; + sandbox_enabled: boolean; + sandbox_schema: string | null; +} + +export interface SandboxToggleResponse { + sandbox_mode: boolean; + message: string; +} + +export interface SandboxResetResponse { + message: string; + sandbox_schema: string; +} + +/** + * Get current sandbox mode status + */ +export const getSandboxStatus = async (): Promise => { + const response = await apiClient.get('/api/sandbox/status/'); + return response.data; +}; + +/** + * Toggle between live and sandbox mode + */ +export const toggleSandboxMode = async (enableSandbox: boolean): Promise => { + const response = await apiClient.post('/api/sandbox/toggle/', { + sandbox: enableSandbox, + }); + return response.data; +}; + +/** + * Reset sandbox data to initial state + */ +export const resetSandboxData = async (): Promise => { + const response = await apiClient.post('/api/sandbox/reset/'); + return response.data; +}; diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx new file mode 100644 index 0000000..53c9e70 --- /dev/null +++ b/frontend/src/components/ApiTokensSection.tsx @@ -0,0 +1,671 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Key, + Plus, + Copy, + Check, + Trash2, + Eye, + EyeOff, + Clock, + Shield, + AlertTriangle, + ExternalLink, + ChevronDown, + ChevronUp, + X, +} from 'lucide-react'; +import { + useApiTokens, + useCreateApiToken, + useRevokeApiToken, + useUpdateApiToken, + API_SCOPES, + SCOPE_PRESETS, + APIToken, + APITokenCreateResponse, +} from '../hooks/useApiTokens'; + +interface NewTokenModalProps { + isOpen: boolean; + onClose: () => void; + onTokenCreated: (token: APITokenCreateResponse) => void; +} + +const NewTokenModal: React.FC = ({ isOpen, onClose, onTokenCreated }) => { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [selectedScopes, setSelectedScopes] = useState([]); + const [expiresIn, setExpiresIn] = useState('never'); + const [showAdvanced, setShowAdvanced] = useState(false); + const createMutation = useCreateApiToken(); + + const handlePresetSelect = (presetKey: keyof typeof SCOPE_PRESETS) => { + setSelectedScopes(SCOPE_PRESETS[presetKey].scopes); + }; + + const toggleScope = (scope: string) => { + setSelectedScopes(prev => + prev.includes(scope) + ? prev.filter(s => s !== scope) + : [...prev, scope] + ); + }; + + const calculateExpiryDate = (): string | null => { + if (expiresIn === 'never') return null; + const now = new Date(); + switch (expiresIn) { + case '7d': + now.setDate(now.getDate() + 7); + break; + case '30d': + now.setDate(now.getDate() + 30); + break; + case '90d': + now.setDate(now.getDate() + 90); + break; + case '1y': + now.setFullYear(now.getFullYear() + 1); + break; + default: + return null; + } + return now.toISOString(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || selectedScopes.length === 0) return; + + try { + const result = await createMutation.mutateAsync({ + name: name.trim(), + scopes: selectedScopes, + expires_at: calculateExpiryDate(), + }); + onTokenCreated(result); + setName(''); + setSelectedScopes([]); + setExpiresIn('never'); + } catch (error) { + console.error('Failed to create token:', error); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

+ Create API Token +

+ +
+
+ +
+ {/* Token Name */} +
+ + setName(e.target.value)} + placeholder="e.g., Website Integration, Mobile App" + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent" + required + /> +

+ Choose a descriptive name to identify this token's purpose +

+
+ + {/* Scope Presets */} +
+ +
+ {Object.entries(SCOPE_PRESETS).map(([key, preset]) => ( + + ))} +
+
+ + {/* Advanced: Individual Scopes */} +
+ + + {showAdvanced && ( +
+ {API_SCOPES.map((scope) => ( + + ))} +
+ )} +
+ + {/* Expiration */} +
+ + +
+ + {/* Selected Scopes Summary */} + {selectedScopes.length > 0 && ( +
+
+ Selected permissions ({selectedScopes.length}) +
+
+ {selectedScopes.map((scope) => ( + + {scope} + + ))} +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +}; + +interface TokenCreatedModalProps { + token: APITokenCreateResponse | null; + onClose: () => void; +} + +const TokenCreatedModal: React.FC = ({ token, onClose }) => { + const [copied, setCopied] = useState(false); + const [showToken, setShowToken] = useState(false); + + const handleCopy = () => { + if (token) { + navigator.clipboard.writeText(token.key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (!token) return null; + + return ( +
+
+
+
+
+ +
+
+

+ Token Created +

+

+ {token.name} +

+
+
+ +
+
+ +
+ Important: Copy your token now. You won't be able to see it again! +
+
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+
+ ); +}; + +interface TokenRowProps { + token: APIToken; + onRevoke: (id: string, name: string) => void; + isRevoking: boolean; +} + +const TokenRow: React.FC = ({ token, onRevoke, isRevoking }) => { + const [expanded, setExpanded] = useState(false); + + const formatDate = (dateString: string | null) => { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const isExpired = token.expires_at && new Date(token.expires_at) < new Date(); + + return ( +
+
+
+
+
+ +
+
+
+ + {token.name} + + {(!token.is_active || isExpired) && ( + + {isExpired ? 'Expired' : 'Revoked'} + + )} +
+
+ {token.key_prefix}•••••••• +
+
+
+ +
+ + {token.is_active && !isExpired && ( + + )} +
+
+ + {expanded && ( +
+
+
+
Created
+
{formatDate(token.created_at)}
+
+
+
Last Used
+
{formatDate(token.last_used_at)}
+
+
+
Expires
+
+ {formatDate(token.expires_at)} +
+
+ {token.created_by && ( +
+
Created By
+
{token.created_by.full_name || token.created_by.username}
+
+ )} +
+ +
+
Permissions
+
+ {token.scopes.map((scope) => ( + + {scope} + + ))} +
+
+
+ )} +
+
+ ); +}; + +const ApiTokensSection: React.FC = () => { + const { t } = useTranslation(); + const { data: tokens, isLoading, error } = useApiTokens(); + const revokeMutation = useRevokeApiToken(); + const [showNewTokenModal, setShowNewTokenModal] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + const [tokenToRevoke, setTokenToRevoke] = useState<{ id: string; name: string } | null>(null); + + const handleTokenCreated = (token: APITokenCreateResponse) => { + setShowNewTokenModal(false); + setCreatedToken(token); + }; + + const handleRevokeClick = (id: string, name: string) => { + setTokenToRevoke({ id, name }); + }; + + const confirmRevoke = async () => { + if (!tokenToRevoke) return; + setTokenToRevoke(null); + await revokeMutation.mutateAsync(tokenToRevoke.id); + }; + + const activeTokens = tokens?.filter(t => t.is_active) || []; + const revokedTokens = tokens?.filter(t => !t.is_active) || []; + + return ( + <> + {/* Revoke Confirmation Modal */} + {tokenToRevoke && ( +
+
+
+
+ +
+
+

+ Revoke API Token? +

+

+ Are you sure you want to revoke {tokenToRevoke.name}? +

+

+ This action cannot be undone. Applications using this token will immediately lose access. +

+
+
+
+ + +
+
+
+ )} + +
+
+
+

+ + API Tokens +

+

+ Create and manage API tokens for third-party integrations +

+
+
+ + + API Docs + + +
+
+ + {isLoading ? ( +
+
+
+ ) : error ? ( +
+

+ Failed to load API tokens. Please try again later. +

+
+ ) : tokens && tokens.length === 0 ? ( +
+
+ +
+

+ No API tokens yet +

+

+ Create your first API token to start integrating with external services and applications. +

+ +
+ ) : ( +
+ {activeTokens.length > 0 && ( +
+

+ + Active Tokens ({activeTokens.length}) +

+
+ {activeTokens.map((token) => ( + + ))} +
+
+ )} + + {revokedTokens.length > 0 && ( +
+

+ + Revoked Tokens ({revokedTokens.length}) +

+
+ {revokedTokens.map((token) => ( + + ))} +
+
+ )} +
+ )} +
+ + {/* Modals */} + setShowNewTokenModal(false)} + onTokenCreated={handleTokenCreated} + /> + setCreatedToken(null)} + /> + + ); +}; + +export default ApiTokensSection; diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx index b9402db..27f77dd 100644 --- a/frontend/src/components/LanguageSelector.tsx +++ b/frontend/src/components/LanguageSelector.tsx @@ -79,7 +79,7 @@ const LanguageSelector: React.FC = ({ {isOpen && ( -
+
    {supportedLanguages.map((lang) => (
  • diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx new file mode 100644 index 0000000..4b509dd --- /dev/null +++ b/frontend/src/components/NotificationDropdown.tsx @@ -0,0 +1,229 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react'; +import { + useNotifications, + useUnreadNotificationCount, + useMarkNotificationRead, + useMarkAllNotificationsRead, + useClearAllNotifications, +} from '../hooks/useNotifications'; +import { Notification } from '../api/notifications'; + +interface NotificationDropdownProps { + variant?: 'light' | 'dark'; + onTicketClick?: (ticketId: string) => void; +} + +const NotificationDropdown: React.FC = ({ variant = 'dark', onTicketClick }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const { data: notifications = [], isLoading } = useNotifications({ limit: 20 }); + const { data: unreadCount = 0 } = useUnreadNotificationCount(); + const markReadMutation = useMarkNotificationRead(); + const markAllReadMutation = useMarkAllNotificationsRead(); + const clearAllMutation = useClearAllNotifications(); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleNotificationClick = (notification: Notification) => { + // Mark as read + if (!notification.read) { + markReadMutation.mutate(notification.id); + } + + // Handle ticket notifications specially - open modal instead of navigating + if (notification.target_type === 'ticket' && onTicketClick) { + const ticketId = notification.data?.ticket_id; + if (ticketId) { + onTicketClick(String(ticketId)); + setIsOpen(false); + return; + } + } + + // Navigate to target if available + if (notification.target_url) { + navigate(notification.target_url); + setIsOpen(false); + } + }; + + const handleMarkAllRead = () => { + markAllReadMutation.mutate(); + }; + + const handleClearAll = () => { + clearAllMutation.mutate(); + }; + + const getNotificationIcon = (targetType: string | null) => { + switch (targetType) { + case 'ticket': + return ; + case 'event': + case 'appointment': + return ; + default: + return ; + } + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return t('notifications.justNow', 'Just now'); + if (diffMins < 60) return t('notifications.minutesAgo', '{{count}}m ago', { count: diffMins }); + if (diffHours < 24) return t('notifications.hoursAgo', '{{count}}h ago', { count: diffHours }); + if (diffDays < 7) return t('notifications.daysAgo', '{{count}}d ago', { count: diffDays }); + return date.toLocaleDateString(); + }; + + const buttonClasses = variant === 'light' + ? 'p-2 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors' + : 'relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700'; + + return ( +
    + {/* Bell Button */} + + + {/* Dropdown */} + {isOpen && ( +
    + {/* Header */} +
    +

    + {t('notifications.title', 'Notifications')} +

    +
    + {unreadCount > 0 && ( + + )} + +
    +
    + + {/* Notification List */} +
    + {isLoading ? ( +
    + {t('common.loading')} +
    + ) : notifications.length === 0 ? ( +
    + +

    + {t('notifications.noNotifications', 'No notifications yet')} +

    +
    + ) : ( +
    + {notifications.map((notification) => ( + + ))} +
    + )} +
    + + {/* Footer */} + {notifications.length > 0 && ( +
    + + +
    + )} +
    + )} +
    + ); +}; + +export default NotificationDropdown; diff --git a/frontend/src/components/PlatformSidebar.tsx b/frontend/src/components/PlatformSidebar.tsx index 06cc1c5..9e51a12 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 } from 'lucide-react'; +import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code } from 'lucide-react'; import { User } from '../types'; import SmoothScheduleLogo from './SmoothScheduleLogo'; @@ -77,6 +77,18 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to )} + + {/* Help Section */} +
    + + + {!isCollapsed && {t('nav.help', 'Help')}} + + + + {!isCollapsed && {t('nav.apiDocs', 'API Docs')}} + +
); diff --git a/frontend/src/components/SandboxBanner.tsx b/frontend/src/components/SandboxBanner.tsx new file mode 100644 index 0000000..7e9dc21 --- /dev/null +++ b/frontend/src/components/SandboxBanner.tsx @@ -0,0 +1,79 @@ +/** + * Sandbox Banner Component + * Displays a prominent warning banner when in test/sandbox mode + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlaskConical, X } from 'lucide-react'; + +interface SandboxBannerProps { + /** Whether sandbox mode is currently active */ + isSandbox: boolean; + /** Callback to switch to live mode */ + onSwitchToLive: () => void; + /** Optional: Allow dismissing the banner (it will reappear on page reload) */ + onDismiss?: () => void; + /** Whether switching is in progress */ + isSwitching?: boolean; +} + +const SandboxBanner: React.FC = ({ + isSandbox, + onSwitchToLive, + onDismiss, + isSwitching = false, +}) => { + const { t } = useTranslation(); + + // Don't render if not in sandbox mode + if (!isSandbox) { + return null; + } + + return ( +
+
+ +
+ + {t('sandbox.bannerTitle', 'TEST MODE')} + + + {t('sandbox.bannerDescription', 'You are viewing test data. Changes here won\'t affect your live business.')} + +
+
+ +
+ + + {onDismiss && ( + + )} +
+
+ ); +}; + +export default SandboxBanner; diff --git a/frontend/src/components/SandboxToggle.tsx b/frontend/src/components/SandboxToggle.tsx new file mode 100644 index 0000000..6f242d8 --- /dev/null +++ b/frontend/src/components/SandboxToggle.tsx @@ -0,0 +1,80 @@ +/** + * Sandbox Toggle Component + * A toggle switch to switch between Live and Test modes + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlaskConical, Zap } from 'lucide-react'; + +interface SandboxToggleProps { + /** Whether sandbox mode is currently active */ + isSandbox: boolean; + /** Whether sandbox mode is available for this business */ + sandboxEnabled: boolean; + /** Callback when mode is toggled */ + onToggle: (enableSandbox: boolean) => void; + /** Whether a toggle operation is in progress */ + isToggling?: boolean; + /** Optional additional CSS classes */ + className?: string; +} + +const SandboxToggle: React.FC = ({ + isSandbox, + sandboxEnabled, + onToggle, + isToggling = false, + className = '', +}) => { + const { t } = useTranslation(); + + // Don't render if sandbox is not enabled for this business + if (!sandboxEnabled) { + return null; + } + + return ( +
+ {/* Live Mode Button */} + + + {/* Test Mode Button */} + +
+ ); +}; + +export default SandboxToggle; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 09f6516..10b6416 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; import { @@ -11,7 +11,13 @@ import { LogOut, ClipboardList, Briefcase, - Ticket + Ticket, + HelpCircle, + Code, + ChevronDown, + BookOpen, + FileQuestion, + LifeBuoy } from 'lucide-react'; import { Business, User } from '../types'; import { useLogout } from '../hooks/useAuth'; @@ -29,6 +35,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const location = useLocation(); const { role } = user; const logoutMutation = useLogout(); + const [isHelpOpen, setIsHelpOpen] = useState(location.pathname.startsWith('/help') || location.pathname === '/support'); const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => { const isActive = exact @@ -174,6 +181,62 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo {!isCollapsed && {t('nav.staff')}} + {/* Help Dropdown */} +
+ + {isHelpOpen && !isCollapsed && ( +
+ + + {t('nav.platformGuide', 'Platform Guide')} + + + + {t('nav.ticketingHelp', 'Ticketing System')} + + {role === 'owner' && ( + + + {t('nav.apiDocs', 'API Docs')} + + )} +
+ + + {t('nav.support', 'Support')} + +
+
+ )} +
)} @@ -217,4 +280,4 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo ); }; -export default Sidebar; \ No newline at end of file +export default Sidebar; diff --git a/frontend/src/components/StaffPermissions.tsx b/frontend/src/components/StaffPermissions.tsx new file mode 100644 index 0000000..94ae6ff --- /dev/null +++ b/frontend/src/components/StaffPermissions.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface PermissionConfig { + key: string; + labelKey: string; + labelDefault: string; + hintKey: string; + hintDefault: string; + defaultValue: boolean; + roles: ('manager' | 'staff')[]; +} + +// Define all available permissions in one place +export const PERMISSION_CONFIGS: PermissionConfig[] = [ + // Manager-only permissions + { + key: 'can_invite_staff', + labelKey: 'staff.canInviteStaff', + labelDefault: 'Can invite new staff members', + hintKey: 'staff.canInviteStaffHint', + hintDefault: 'Allow this manager to send invitations to new staff members', + defaultValue: false, + roles: ['manager'], + }, + { + key: 'can_manage_resources', + labelKey: 'staff.canManageResources', + labelDefault: 'Can manage resources', + hintKey: 'staff.canManageResourcesHint', + hintDefault: 'Create, edit, and delete bookable resources', + defaultValue: true, + roles: ['manager'], + }, + { + key: 'can_manage_services', + labelKey: 'staff.canManageServices', + labelDefault: 'Can manage services', + hintKey: 'staff.canManageServicesHint', + hintDefault: 'Create, edit, and delete service offerings', + defaultValue: true, + roles: ['manager'], + }, + { + key: 'can_view_reports', + labelKey: 'staff.canViewReports', + labelDefault: 'Can view reports', + hintKey: 'staff.canViewReportsHint', + hintDefault: 'Access business analytics and financial reports', + defaultValue: true, + roles: ['manager'], + }, + { + key: 'can_access_settings', + labelKey: 'staff.canAccessSettings', + labelDefault: 'Can access business settings', + hintKey: 'staff.canAccessSettingsHint', + hintDefault: 'Modify business profile, branding, and configuration', + defaultValue: false, + roles: ['manager'], + }, + { + key: 'can_refund_payments', + labelKey: 'staff.canRefundPayments', + labelDefault: 'Can refund payments', + hintKey: 'staff.canRefundPaymentsHint', + hintDefault: 'Process refunds for customer payments', + defaultValue: false, + roles: ['manager'], + }, + // Staff-only permissions + { + key: 'can_view_all_schedules', + labelKey: 'staff.canViewAllSchedules', + labelDefault: 'Can view all schedules', + hintKey: 'staff.canViewAllSchedulesHint', + hintDefault: 'View schedules of other staff members (otherwise only their own)', + defaultValue: false, + roles: ['staff'], + }, + { + key: 'can_manage_own_appointments', + labelKey: 'staff.canManageOwnAppointments', + labelDefault: 'Can manage own appointments', + hintKey: 'staff.canManageOwnAppointmentsHint', + hintDefault: 'Create, reschedule, and cancel their own appointments', + defaultValue: true, + roles: ['staff'], + }, + // Shared permissions (both manager and staff) + { + key: 'can_access_tickets', + labelKey: 'staff.canAccessTickets', + labelDefault: 'Can access support tickets', + hintKey: 'staff.canAccessTicketsHint', + hintDefault: 'View and manage customer support tickets', + defaultValue: true, // Default for managers; staff will override to false + roles: ['manager', 'staff'], + }, +]; + +// Get default permissions for a role +export const getDefaultPermissions = (role: 'manager' | 'staff'): Record => { + const defaults: Record = {}; + PERMISSION_CONFIGS.forEach((config) => { + if (config.roles.includes(role)) { + // Staff members have ticket access disabled by default + if (role === 'staff' && config.key === 'can_access_tickets') { + defaults[config.key] = false; + } else { + defaults[config.key] = config.defaultValue; + } + } + }); + return defaults; +}; + +interface StaffPermissionsProps { + role: 'manager' | 'staff'; + permissions: Record; + onChange: (permissions: Record) => void; + variant?: 'invite' | 'edit'; +} + +const StaffPermissions: React.FC = ({ + role, + permissions, + onChange, + variant = 'edit', +}) => { + const { t } = useTranslation(); + + // Filter permissions for this role + const rolePermissions = PERMISSION_CONFIGS.filter((config) => + config.roles.includes(role) + ); + + const handleToggle = (key: string, checked: boolean) => { + onChange({ ...permissions, [key]: checked }); + }; + + // Get the current value, falling back to default + const getValue = (config: PermissionConfig): boolean => { + if (permissions[config.key] !== undefined) { + return permissions[config.key]; + } + // Staff have ticket access disabled by default + if (role === 'staff' && config.key === 'can_access_tickets') { + return false; + } + return config.defaultValue; + }; + + // Different styling for manager vs staff permissions + const isManagerPermission = (config: PermissionConfig) => + config.roles.includes('manager') && !config.roles.includes('staff'); + + const getPermissionStyle = (config: PermissionConfig) => { + if (isManagerPermission(config) || role === 'manager') { + return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30'; + } + return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'; + }; + + if (rolePermissions.length === 0) { + return null; + } + + return ( +
+

+ {role === 'manager' + ? t('staff.managerPermissions', 'Manager Permissions') + : t('staff.staffPermissions', 'Staff Permissions')} +

+ + {rolePermissions.map((config) => ( + + ))} +
+ ); +}; + +export default StaffPermissions; diff --git a/frontend/src/components/TicketModal.tsx b/frontend/src/components/TicketModal.tsx index 6958618..712bf3a 100644 --- a/frontend/src/components/TicketModal.tsx +++ b/frontend/src/components/TicketModal.tsx @@ -5,6 +5,7 @@ import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory, Ti import { useCreateTicket, useUpdateTicket, useTicketComments, useCreateTicketComment } from '../hooks/useTickets'; import { useStaffForAssignment } from '../hooks/useUsers'; import { useQueryClient } from '@tanstack/react-query'; +import { useSandbox } from '../contexts/SandboxContext'; interface TicketModalProps { ticket?: Ticket | null; // If provided, it's an edit/detail view @@ -23,6 +24,7 @@ const CATEGORY_OPTIONS: Record = { const TicketModal: React.FC = ({ ticket, onClose, defaultTicketType = 'CUSTOMER' }) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { isSandbox } = useSandbox(); const [subject, setSubject] = useState(ticket?.subject || ''); const [description, setDescription] = useState(ticket?.description || ''); const [priority, setPriority] = useState(ticket?.priority || 'MEDIUM'); @@ -30,8 +32,11 @@ const TicketModal: React.FC = ({ ticket, onClose, defaultTicke const [ticketType, setTicketType] = useState(ticket?.ticketType || defaultTicketType); const [assigneeId, setAssigneeId] = useState(ticket?.assignee); const [status, setStatus] = useState(ticket?.status || 'OPEN'); - const [newCommentText, setNewCommentText] = useState(''); - const [isInternalComment, setIsInternalComment] = useState(false); + const [replyText, setReplyText] = useState(''); + const [internalNoteText, setInternalNoteText] = useState(''); + + // Check if this is a platform ticket in sandbox mode (should be disabled) + const isPlatformTicketInSandbox = ticketType === 'PLATFORM' && isSandbox; // Fetch users for assignee dropdown const { data: users = [] } = useStaffForAssignment(); @@ -96,20 +101,31 @@ const TicketModal: React.FC = ({ ticket, onClose, defaultTicke onClose(); }; - const handleAddComment = async (e: React.FormEvent) => { + const handleAddReply = async (e: React.FormEvent) => { e.preventDefault(); - if (!ticket?.id || !newCommentText.trim()) return; + if (!ticket?.id || !replyText.trim()) return; const commentData: Partial = { - commentText: newCommentText.trim(), - isInternal: isInternalComment, - // author and ticket are handled by the backend + commentText: replyText.trim(), + isInternal: false, }; await createCommentMutation.mutateAsync({ ticketId: ticket.id, commentData }); - setNewCommentText(''); - setIsInternalComment(false); - // Invalidate comments query to refetch new comment + setReplyText(''); + queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] }); + }; + + const handleAddInternalNote = async (e: React.FormEvent) => { + e.preventDefault(); + if (!ticket?.id || !internalNoteText.trim()) return; + + const commentData: Partial = { + commentText: internalNoteText.trim(), + isInternal: true, + }; + + await createCommentMutation.mutateAsync({ ticketId: ticket.id, commentData }); + setInternalNoteText(''); queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] }); }; @@ -130,6 +146,23 @@ const TicketModal: React.FC = ({ ticket, onClose, defaultTicke
+ {/* Sandbox Warning for Platform Tickets */} + {isPlatformTicketInSandbox && ( +
+
+ +
+

+ {t('tickets.sandboxRestriction', 'Platform Support Unavailable in Test Mode')} +

+

+ {t('tickets.sandboxRestrictionMessage', 'You can only contact SmoothSchedule support in live mode. Please switch to live mode to create a support ticket.')} +

+
+
+
+ )} + {/* Form / Details */}
@@ -143,9 +176,9 @@ const TicketModal: React.FC = ({ ticket, onClose, defaultTicke id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" required - disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} // Disable if viewing existing and not actively editing + disabled={isPlatformTicketInSandbox || (!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending)} // Disable in sandbox or if viewing existing />
@@ -159,14 +192,14 @@ const TicketModal: React.FC = ({ ticket, onClose, defaultTicke value={description} onChange={(e) => setDescription(e.target.value)} rows={4} - className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" required - disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} // Disable if viewing existing and not actively editing + disabled={isPlatformTicketInSandbox || (!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending)} // Disable in sandbox or if viewing existing /> - {/* Ticket Type (only for new tickets) */} - {!ticket && ( + {/* Ticket Type (only for new tickets, and hide for platform tickets) */} + {!ticket && ticketType !== 'PLATFORM' && (
)} - {/* Priority & Category */} -
-
- - + {/* Priority & Category - Hide for platform tickets when viewing/creating */} + {ticketType !== 'PLATFORM' && ( +
+
+ + +
+
+ + +
-
- - -
-
+ )} - {/* Assignee & Status (only visible for existing tickets or if user has permission to assign) */} - {ticket && ( + {/* Assignee & Status (only visible for existing non-PLATFORM tickets) */} + {ticket && ticketType !== 'PLATFORM' && (