- Add BroadcastMessage and MessageRecipient models for sending messages to groups or individuals - Add Messages page with compose form and sent messages list - Support targeting by role (owners, managers, staff, customers) or individual users - Add can_send_messages permission (owners always, managers by default with revocable permission) - Add autofill search dropdown with infinite scroll for selecting individual recipients - Add staff permission toggle for managers' messaging access - Integrate Messages link in sidebar for users with permission 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
919 lines
39 KiB
TypeScript
919 lines
39 KiB
TypeScript
/**
|
|
* Main App Component - Integrated with Real API
|
|
*/
|
|
|
|
import React, { useState, Suspense } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
|
import { useCurrentBusiness } from './hooks/useBusiness';
|
|
import { useUpdateBusiness } from './hooks/useBusiness';
|
|
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
|
import { setCookie } from './utils/cookies';
|
|
|
|
// Import Login Page
|
|
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
|
|
const MFAVerifyPage = React.lazy(() => import('./pages/MFAVerifyPage'));
|
|
const OAuthCallback = React.lazy(() => import('./pages/OAuthCallback'));
|
|
|
|
// Import layouts
|
|
import BusinessLayout from './layouts/BusinessLayout';
|
|
import PlatformLayout from './layouts/PlatformLayout';
|
|
import CustomerLayout from './layouts/CustomerLayout';
|
|
import MarketingLayout from './layouts/MarketingLayout';
|
|
|
|
// Import marketing pages
|
|
const HomePage = React.lazy(() => import('./pages/marketing/HomePage'));
|
|
const FeaturesPage = React.lazy(() => import('./pages/marketing/FeaturesPage'));
|
|
const PricingPage = React.lazy(() => import('./pages/marketing/PricingPage'));
|
|
const AboutPage = React.lazy(() => import('./pages/marketing/AboutPage'));
|
|
const ContactPage = React.lazy(() => import('./pages/marketing/ContactPage'));
|
|
const SignupPage = React.lazy(() => import('./pages/marketing/SignupPage'));
|
|
const PrivacyPolicyPage = React.lazy(() => import('./pages/marketing/PrivacyPolicyPage'));
|
|
const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfServicePage'));
|
|
|
|
// Import pages
|
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
|
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
|
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
|
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
|
const Customers = React.lazy(() => import('./pages/Customers'));
|
|
const Settings = React.lazy(() => import('./pages/Settings'));
|
|
const Payments = React.lazy(() => import('./pages/Payments'));
|
|
const Messages = React.lazy(() => import('./pages/Messages'));
|
|
const Resources = React.lazy(() => import('./pages/Resources'));
|
|
const Services = React.lazy(() => import('./pages/Services'));
|
|
const Staff = React.lazy(() => import('./pages/Staff'));
|
|
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
|
|
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
|
|
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
|
|
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
|
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
|
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
|
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
|
|
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
|
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
|
|
|
// Import platform pages
|
|
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
|
|
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
|
|
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
|
|
const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/PlatformEmailAddresses'));
|
|
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 ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
|
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
|
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
|
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
|
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
|
|
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
|
|
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
|
|
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
|
|
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
|
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
|
|
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
|
|
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
|
|
|
|
// Import new help pages
|
|
const HelpDashboard = React.lazy(() => import('./pages/help/HelpDashboard'));
|
|
const HelpScheduler = React.lazy(() => import('./pages/help/HelpScheduler'));
|
|
const HelpTasks = React.lazy(() => import('./pages/help/HelpTasks'));
|
|
const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
|
|
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
|
|
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
|
|
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
|
|
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
|
|
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
|
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
|
|
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
|
|
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
|
|
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
|
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
|
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
|
|
const HelpSettingsAppearance = React.lazy(() => import('./pages/help/HelpSettingsAppearance'));
|
|
const HelpSettingsEmail = React.lazy(() => import('./pages/help/HelpSettingsEmail'));
|
|
const HelpSettingsDomains = React.lazy(() => import('./pages/help/HelpSettingsDomains'));
|
|
const HelpSettingsApi = React.lazy(() => import('./pages/help/HelpSettingsApi'));
|
|
const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'));
|
|
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
|
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
|
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
|
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
|
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
|
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
|
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
|
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
|
|
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
|
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
|
|
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
|
|
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
|
|
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
|
|
|
// Settings pages
|
|
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
|
const GeneralSettings = React.lazy(() => import('./pages/settings/GeneralSettings'));
|
|
const BrandingSettings = React.lazy(() => import('./pages/settings/BrandingSettings'));
|
|
const ResourceTypesSettings = React.lazy(() => import('./pages/settings/ResourceTypesSettings'));
|
|
const BookingSettings = React.lazy(() => import('./pages/settings/BookingSettings'));
|
|
const CustomDomainsSettings = React.lazy(() => import('./pages/settings/CustomDomainsSettings'));
|
|
const ApiSettings = React.lazy(() => import('./pages/settings/ApiSettings'));
|
|
const AuthenticationSettings = React.lazy(() => import('./pages/settings/AuthenticationSettings'));
|
|
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
|
|
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
|
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
|
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
|
|
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
refetchOnWindowFocus: false,
|
|
retry: 1,
|
|
staleTime: 30000, // 30 seconds
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Loading Component
|
|
*/
|
|
const LoadingScreen: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Error Component
|
|
*/
|
|
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center max-w-md">
|
|
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
{t('common.reload')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* App Content - Handles routing based on auth state
|
|
*/
|
|
const AppContent: React.FC = () => {
|
|
// Check for tokens in URL FIRST - before any queries execute
|
|
// This handles login/masquerade redirects that pass tokens in the URL
|
|
const [processingUrlTokens] = useState(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return !!(params.get('access_token') && params.get('refresh_token'));
|
|
});
|
|
|
|
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
|
|
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
|
|
const [darkMode, setDarkMode] = useState(() => {
|
|
// Check localStorage first, then system preference
|
|
const saved = localStorage.getItem('darkMode');
|
|
if (saved !== null) {
|
|
return JSON.parse(saved);
|
|
}
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
});
|
|
const updateBusinessMutation = useUpdateBusiness();
|
|
const masqueradeMutation = useMasquerade();
|
|
const logoutMutation = useLogout();
|
|
const { canUse } = usePlanFeatures();
|
|
|
|
// Apply dark mode class and persist to localStorage
|
|
React.useEffect(() => {
|
|
document.documentElement.classList.toggle('dark', darkMode);
|
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
|
}, [darkMode]);
|
|
|
|
// Set noindex/nofollow for app subdomains (platform, business subdomains)
|
|
// Only the root domain marketing pages should be indexed
|
|
React.useEffect(() => {
|
|
const hostname = window.location.hostname;
|
|
const parts = hostname.split('.');
|
|
const hasSubdomain = parts.length > 2 || (parts.length === 2 && parts[0] !== 'localhost');
|
|
|
|
// Check if we're on a subdomain (platform.*, demo.*, etc.)
|
|
const isSubdomain = hostname !== 'localhost' && hostname !== '127.0.0.1' && parts.length > 2;
|
|
|
|
if (isSubdomain) {
|
|
// Always noindex/nofollow on subdomains (app areas)
|
|
let metaRobots = document.querySelector('meta[name="robots"]');
|
|
if (metaRobots) {
|
|
metaRobots.setAttribute('content', 'noindex, nofollow');
|
|
} else {
|
|
metaRobots = document.createElement('meta');
|
|
metaRobots.setAttribute('name', 'robots');
|
|
metaRobots.setAttribute('content', 'noindex, nofollow');
|
|
document.head.appendChild(metaRobots);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Handle tokens in URL (from login or masquerade redirect)
|
|
React.useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const accessToken = params.get('access_token');
|
|
const refreshToken = params.get('refresh_token');
|
|
|
|
if (accessToken && refreshToken) {
|
|
// Extract masquerade stack if present (for masquerade banner)
|
|
const masqueradeStackParam = params.get('masquerade_stack');
|
|
if (masqueradeStackParam) {
|
|
try {
|
|
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
|
|
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
|
} catch (e) {
|
|
console.error('Failed to parse masquerade stack', e);
|
|
}
|
|
}
|
|
|
|
// For backward compatibility, also check for original_user parameter
|
|
const originalUserParam = params.get('original_user');
|
|
if (originalUserParam && !masqueradeStackParam) {
|
|
try {
|
|
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
|
|
// Convert old format to new stack format (single entry)
|
|
const stack = [{
|
|
user_id: originalUser.id,
|
|
username: originalUser.username,
|
|
role: originalUser.role,
|
|
business_id: originalUser.business,
|
|
business_subdomain: originalUser.business_subdomain,
|
|
}];
|
|
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
|
|
} catch (e) {
|
|
console.error('Failed to parse original user', e);
|
|
}
|
|
}
|
|
|
|
// Set cookies using helper (handles domain correctly)
|
|
setCookie('access_token', accessToken, 7);
|
|
setCookie('refresh_token', refreshToken, 7);
|
|
|
|
// Clean URL
|
|
const newUrl = window.location.pathname + window.location.hash;
|
|
window.history.replaceState({}, '', newUrl);
|
|
|
|
// Force reload to ensure auth state is picked up
|
|
window.location.reload();
|
|
}
|
|
}, []);
|
|
|
|
// Show loading while processing URL tokens (before reload happens)
|
|
if (processingUrlTokens) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Loading state
|
|
if (userLoading) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Helper to detect root domain (for marketing site)
|
|
const isRootDomain = (): boolean => {
|
|
const hostname = window.location.hostname;
|
|
// Root domain has no subdomain (just the base domain like smoothschedule.com or lvh.me)
|
|
const parts = hostname.split('.');
|
|
return hostname === 'localhost' || hostname === '127.0.0.1' || parts.length === 2;
|
|
};
|
|
|
|
// On root domain, ALWAYS show marketing site (even if logged in)
|
|
// Logged-in users will see a "Go to Dashboard" link in the navbar
|
|
if (isRootDomain()) {
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route element={<MarketingLayout user={user} />}>
|
|
<Route path="/" element={<HomePage />} />
|
|
<Route path="/features" element={<FeaturesPage />} />
|
|
<Route path="/pricing" element={<PricingPage />} />
|
|
<Route path="/about" element={<AboutPage />} />
|
|
<Route path="/contact" element={<ContactPage />} />
|
|
<Route path="/signup" element={<SignupPage />} />
|
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
|
<Route path="/terms" element={<TermsOfServicePage />} />
|
|
</Route>
|
|
<Route path="/login" element={<LoginPage />} />
|
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Not authenticated - show appropriate page based on subdomain
|
|
if (!user) {
|
|
const currentHostname = window.location.hostname;
|
|
const hostnameParts = currentHostname.split('.');
|
|
const baseDomain = hostnameParts.length >= 2
|
|
? hostnameParts.slice(-2).join('.')
|
|
: currentHostname;
|
|
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
|
|
const isPlatformSubdomain = hostnameParts[0] === 'platform';
|
|
const currentSubdomain = hostnameParts[0];
|
|
|
|
// Check if we're on a business subdomain (not root, not platform, not api)
|
|
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
|
|
|
|
// For business subdomains, show the tenant landing page with login option
|
|
if (isBusinessSubdomain) {
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
|
|
<Route path="/login" element={<LoginPage />} />
|
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// For root domain or platform subdomain, show marketing site / login
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route element={<MarketingLayout user={user} />}>
|
|
<Route path="/" element={<HomePage />} />
|
|
<Route path="/features" element={<FeaturesPage />} />
|
|
<Route path="/pricing" element={<PricingPage />} />
|
|
<Route path="/about" element={<AboutPage />} />
|
|
<Route path="/contact" element={<ContactPage />} />
|
|
<Route path="/signup" element={<SignupPage />} />
|
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
|
<Route path="/terms" element={<TermsOfServicePage />} />
|
|
</Route>
|
|
<Route path="/login" element={<LoginPage />} />
|
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (userError) {
|
|
return <ErrorScreen error={userError as Error} />;
|
|
}
|
|
|
|
// Subdomain validation for logged-in users
|
|
const currentHostname = window.location.hostname;
|
|
const hostnameParts = currentHostname.split('.');
|
|
const baseDomain = hostnameParts.length >= 2
|
|
? hostnameParts.slice(-2).join('.')
|
|
: currentHostname;
|
|
const protocol = window.location.protocol;
|
|
const isPlatformDomain = currentHostname === `platform.${baseDomain}`;
|
|
const currentSubdomain = hostnameParts[0];
|
|
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain;
|
|
|
|
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
|
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
|
const isCustomer = user.role === 'customer';
|
|
|
|
// RULE: Platform users on business subdomains should be redirected to platform subdomain
|
|
if (isPlatformUser && isBusinessSubdomain) {
|
|
const port = window.location.port ? `:${window.location.port}` : '';
|
|
window.location.href = `${protocol}//platform.${baseDomain}${port}/`;
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// RULE: Business users must be on their own business subdomain
|
|
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
|
const port = window.location.port ? `:${window.location.port}` : '';
|
|
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// RULE: Customers must be on their business subdomain
|
|
if (isCustomer && isPlatformDomain && user.business_subdomain) {
|
|
const port = window.location.port ? `:${window.location.port}` : '';
|
|
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
|
const port = window.location.port ? `:${window.location.port}` : '';
|
|
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Handlers
|
|
const toggleTheme = () => setDarkMode((prev) => !prev);
|
|
const handleSignOut = () => {
|
|
logoutMutation.mutate();
|
|
};
|
|
const handleUpdateBusiness = (updates: Partial<any>) => {
|
|
updateBusinessMutation.mutate(updates);
|
|
};
|
|
|
|
const handleMasquerade = (targetUser: any) => {
|
|
// Call the masquerade API with the target user's id
|
|
const userId = targetUser.id;
|
|
if (!userId) {
|
|
console.error('Cannot masquerade: no user id available', targetUser);
|
|
return;
|
|
}
|
|
// Ensure userId is a number
|
|
const userPk = typeof userId === 'string' ? parseInt(userId, 10) : userId;
|
|
masqueradeMutation.mutate(userPk);
|
|
};
|
|
|
|
// Helper to check access based on roles
|
|
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
|
|
|
if (isPlatformUser) {
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route
|
|
element={
|
|
<PlatformLayout
|
|
user={user}
|
|
darkMode={darkMode}
|
|
toggleTheme={toggleTheme}
|
|
onSignOut={handleSignOut}
|
|
/>
|
|
}
|
|
>
|
|
{(user.role === 'superuser' || user.role === 'platform_manager') && (
|
|
<>
|
|
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
|
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
|
|
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
|
<Route path="/platform/staff" element={<PlatformStaff />} />
|
|
</>
|
|
)}
|
|
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
|
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
|
|
<Route path="/help/guide" element={<HelpGuide />} />
|
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
|
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
|
<Route path="/help/email" element={<HelpEmailSettings />} />
|
|
{user.role === 'superuser' && (
|
|
<Route path="/platform/settings" element={<PlatformSettings />} />
|
|
)}
|
|
<Route path="/platform/profile" element={<ProfileSettings />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route
|
|
path="*"
|
|
element={
|
|
<Navigate
|
|
to={
|
|
user.role === 'superuser' || user.role === 'platform_manager'
|
|
? '/platform/dashboard'
|
|
: '/platform/support'
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
</Route>
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Customer users
|
|
if (user.role === 'customer') {
|
|
// Wait for business data to load
|
|
if (businessLoading) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Handle business not found for customers
|
|
if (!business) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center max-w-md p-6">
|
|
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
Unable to load business data. Please try again.
|
|
</p>
|
|
<button
|
|
onClick={handleSignOut}
|
|
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
|
>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route
|
|
element={
|
|
<CustomerLayout
|
|
business={business}
|
|
user={user}
|
|
darkMode={darkMode}
|
|
toggleTheme={toggleTheme}
|
|
/>
|
|
}
|
|
>
|
|
<Route path="/" element={<CustomerDashboard />} />
|
|
<Route path="/book" element={<BookingPage />} />
|
|
<Route path="/payments" element={<CustomerBilling />} />
|
|
<Route path="/support" element={<CustomerSupport />} />
|
|
<Route path="/profile" element={<ProfileSettings />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="*" element={<Navigate to="/" />} />
|
|
</Route>
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Business loading - show loading with user info
|
|
if (businessLoading) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Business error or no business found
|
|
if (businessError || !business) {
|
|
// If user has a business subdomain, redirect them there
|
|
if (user.business_subdomain) {
|
|
window.location.href = buildSubdomainUrl(user.business_subdomain, '/');
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// No business subdomain - show error
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center max-w-md p-6">
|
|
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
|
|
</p>
|
|
<div className="flex gap-4 justify-center">
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
Reload
|
|
</button>
|
|
<button
|
|
onClick={handleSignOut}
|
|
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
|
>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Business users (owner, manager, staff, resource)
|
|
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
|
// Check if email verification is required
|
|
if (!user.email_verified) {
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Check if trial has expired
|
|
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
|
|
|
// Allowed routes when trial is expired
|
|
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
|
|
const currentPath = window.location.pathname;
|
|
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
|
|
|
// If trial expired and not on allowed route, redirect to trial-expired
|
|
if (isTrialExpired && !isOnAllowedRoute) {
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route path="/trial-expired" element={<TrialExpired />} />
|
|
<Route path="/upgrade" element={<Upgrade />} />
|
|
<Route path="/profile" element={<ProfileSettings />} />
|
|
{/* Trial-expired users can access billing settings to upgrade */}
|
|
<Route
|
|
path="/settings/*"
|
|
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
|
|
/>
|
|
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route
|
|
element={
|
|
<BusinessLayout
|
|
business={business}
|
|
user={user}
|
|
darkMode={darkMode}
|
|
toggleTheme={toggleTheme}
|
|
onSignOut={handleSignOut}
|
|
updateBusiness={handleUpdateBusiness}
|
|
/>
|
|
}
|
|
>
|
|
{/* Trial and Upgrade Routes */}
|
|
<Route path="/trial-expired" element={<TrialExpired />} />
|
|
<Route path="/upgrade" element={<Upgrade />} />
|
|
|
|
{/* Regular Routes */}
|
|
<Route
|
|
path="/"
|
|
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
|
/>
|
|
{/* Staff Schedule - vertical timeline view */}
|
|
<Route
|
|
path="/my-schedule"
|
|
element={
|
|
hasAccess(['staff']) ? (
|
|
<StaffSchedule user={user} />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/scheduler" element={<Scheduler />} />
|
|
<Route path="/tickets" element={<Tickets />} />
|
|
<Route
|
|
path="/help"
|
|
element={
|
|
user.role === 'staff' ? (
|
|
<StaffHelp user={user} />
|
|
) : (
|
|
<HelpComprehensive />
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/help/guide" element={<HelpGuide />} />
|
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
|
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
|
|
<Route path="/help/email" element={<HelpEmailSettings />} />
|
|
{/* New help pages */}
|
|
<Route path="/help/dashboard" element={<HelpDashboard />} />
|
|
<Route path="/help/scheduler" element={<HelpScheduler />} />
|
|
<Route path="/help/tasks" element={<HelpTasks />} />
|
|
<Route path="/help/customers" element={<HelpCustomers />} />
|
|
<Route path="/help/services" element={<HelpServices />} />
|
|
<Route path="/help/resources" element={<HelpResources />} />
|
|
<Route path="/help/staff" element={<HelpStaff />} />
|
|
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
|
|
<Route path="/help/messages" element={<HelpMessages />} />
|
|
<Route path="/help/payments" element={<HelpPayments />} />
|
|
<Route path="/help/contracts" element={<HelpContracts />} />
|
|
<Route path="/help/plugins" element={<HelpPlugins />} />
|
|
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
|
|
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
|
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
|
|
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
|
|
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
|
|
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
|
|
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
|
|
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
|
|
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
|
|
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
|
|
<Route
|
|
path="/plugins/marketplace"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<PluginMarketplace />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/plugins/my-plugins"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<MyPlugins />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/plugins/create"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<CreatePlugin />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/tasks"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Tasks />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/email-templates"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<EmailTemplates />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/support" element={<PlatformSupport />} />
|
|
<Route
|
|
path="/customers"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/services"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Services />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/resources"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/staff"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/time-blocks"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<TimeBlocks />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/my-availability"
|
|
element={
|
|
hasAccess(['staff', 'resource']) ? (
|
|
<MyAvailability user={user} />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/contracts"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
|
<Contracts />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/contracts/templates"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
|
<ContractTemplates />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/payments"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
|
}
|
|
/>
|
|
<Route
|
|
path="/messages"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
|
<Messages />
|
|
) : (
|
|
<Navigate to="/" />
|
|
)
|
|
}
|
|
/>
|
|
{/* Settings Routes with Nested Layout */}
|
|
{hasAccess(['owner']) ? (
|
|
<Route path="/settings" element={<SettingsLayout />}>
|
|
<Route index element={<Navigate to="/settings/general" replace />} />
|
|
<Route path="general" element={<GeneralSettings />} />
|
|
<Route path="branding" element={<BrandingSettings />} />
|
|
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
|
<Route path="booking" element={<BookingSettings />} />
|
|
<Route path="email-templates" element={<EmailTemplates />} />
|
|
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
|
<Route path="api" element={<ApiSettings />} />
|
|
<Route path="authentication" element={<AuthenticationSettings />} />
|
|
<Route path="email" element={<EmailSettings />} />
|
|
<Route path="sms-calling" element={<CommunicationSettings />} />
|
|
<Route path="billing" element={<BillingSettings />} />
|
|
<Route path="quota" element={<QuotaSettings />} />
|
|
</Route>
|
|
) : (
|
|
<Route path="/settings/*" element={<Navigate to="/" />} />
|
|
)}
|
|
<Route path="/profile" element={<ProfileSettings />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
<Route path="*" element={<Navigate to="/" />} />
|
|
</Route>
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Fallback
|
|
return <Navigate to="/" />;
|
|
};
|
|
|
|
/**
|
|
* Main App Component
|
|
*/
|
|
const App: React.FC = () => {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<Router>
|
|
<AppContent />
|
|
</Router>
|
|
<Toaster /> {/* Add Toaster component for notifications */}
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
export default App; |