Replaced the blank base64 encoded logo with the actual SmoothSchedule logo in the email rendering pipeline. A Playwright E2E test was run to verify that the logo is correctly displayed in the email preview modal, ensuring it loads with natural dimensions and is visible.
994 lines
43 KiB
TypeScript
994 lines
43 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 PlatformLoginPage = React.lazy(() => import('./pages/platform/PlatformLoginPage'));
|
|
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 BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
|
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 SystemEmailTemplates = React.lazy(() => import('./pages/settings/SystemEmailTemplates')); // System email templates (Puck-based)
|
|
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)
|
|
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
|
|
const EmailTemplateEditor = React.lazy(() => import('./pages/EmailTemplateEditor')); // Import Email Template Editor
|
|
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
|
|
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
|
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
|
|
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
|
|
|
|
// 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'));
|
|
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
|
|
|
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={<PublicPage />} />
|
|
<Route path="/book" element={<BookingFlow />} />
|
|
<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 platform subdomain, only /platform/login exists - everything else renders nothing
|
|
if (isPlatformSubdomain) {
|
|
const path = window.location.pathname;
|
|
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
|
|
|
|
// If not an allowed path, render nothing
|
|
if (!allowedPaths.includes(path)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
<Route path="/platform/login" element={<PlatformLoginPage />} />
|
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// For root domain, show marketing site with business user 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/billing" element={<BillingManagement />} />
|
|
</>
|
|
)}
|
|
<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 = ['/dashboard/trial-expired', '/dashboard/upgrade', '/dashboard/settings', '/dashboard/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="/dashboard/trial-expired" element={<TrialExpired />} />
|
|
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
|
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
|
{/* Trial-expired users can access billing settings to upgrade */}
|
|
<Route
|
|
path="/dashboard/settings/*"
|
|
element={hasAccess(['owner']) ? <Navigate to="/dashboard/upgrade" /> : <Navigate to="/dashboard/trial-expired" />}
|
|
/>
|
|
<Route path="*" element={<Navigate to="/dashboard/trial-expired" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<LoadingScreen />}>
|
|
<Routes>
|
|
{/* Public routes outside BusinessLayout */}
|
|
<Route path="/" element={<PublicPage />} />
|
|
<Route path="/book" element={<BookingFlow />} />
|
|
<Route path="/login" element={<LoginPage />} />
|
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
|
|
|
{/* Dashboard routes inside BusinessLayout */}
|
|
<Route
|
|
element={
|
|
<BusinessLayout
|
|
business={business}
|
|
user={user}
|
|
darkMode={darkMode}
|
|
toggleTheme={toggleTheme}
|
|
onSignOut={handleSignOut}
|
|
updateBusiness={handleUpdateBusiness}
|
|
/>
|
|
}
|
|
>
|
|
{/* Trial and Upgrade Routes */}
|
|
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
|
|
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
|
|
|
{/* Regular Routes */}
|
|
<Route
|
|
path="/dashboard"
|
|
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
|
/>
|
|
{/* Staff Schedule - vertical timeline view */}
|
|
<Route
|
|
path="/dashboard/my-schedule"
|
|
element={
|
|
hasAccess(['staff']) ? (
|
|
<StaffSchedule user={user} />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/dashboard/scheduler" element={<Scheduler />} />
|
|
<Route path="/dashboard/tickets" element={<Tickets />} />
|
|
<Route
|
|
path="/dashboard/help"
|
|
element={
|
|
user.role === 'staff' ? (
|
|
<StaffHelp user={user} />
|
|
) : (
|
|
<HelpComprehensive />
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
|
|
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
|
|
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
|
|
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
|
|
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
|
|
{/* New help pages */}
|
|
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
|
|
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
|
|
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
|
|
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
|
|
<Route path="/dashboard/help/services" element={<HelpServices />} />
|
|
<Route path="/dashboard/help/resources" element={<HelpResources />} />
|
|
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
|
|
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
|
|
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
|
|
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
|
|
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
|
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
|
|
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
|
|
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
|
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
|
|
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
|
|
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
|
|
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
|
|
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
|
|
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
|
|
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
|
|
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
|
|
<Route
|
|
path="/dashboard/plugins/marketplace"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<PluginMarketplace />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/plugins/my-plugins"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<MyPlugins />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/plugins/create"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<CreatePlugin />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/tasks"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Tasks />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
{/* Email templates are now accessed via Settings > Email Templates */}
|
|
<Route path="/dashboard/support" element={<PlatformSupport />} />
|
|
<Route
|
|
path="/dashboard/customers"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/services"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Services />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/resources"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/staff"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/time-blocks"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<TimeBlocks />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/locations"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<Locations />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/my-availability"
|
|
element={
|
|
hasAccess(['staff', 'resource']) ? (
|
|
<MyAvailability user={user} />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/contracts"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
|
<Contracts />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/contracts/templates"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
|
<ContractTemplates />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/payments"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/messages"
|
|
element={
|
|
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
|
<Messages />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/site-editor"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<PageEditor />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/email-template-editor/:emailType"
|
|
element={
|
|
hasAccess(['owner']) ? (
|
|
<EmailTemplateEditor />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="/dashboard/gallery"
|
|
element={
|
|
hasAccess(['owner', 'manager']) ? (
|
|
<MediaGalleryPage />
|
|
) : (
|
|
<Navigate to="/dashboard" />
|
|
)
|
|
}
|
|
/>
|
|
{/* Settings Routes with Nested Layout */}
|
|
{hasAccess(['owner']) ? (
|
|
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
|
<Route index element={<Navigate to="/dashboard/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="business-hours" element={<BusinessHoursSettings />} />
|
|
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
|
<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="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
|
|
)}
|
|
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
|
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
|
|
</Route>
|
|
|
|
{/* Catch-all redirects to home */}
|
|
<Route path="*" element={<Navigate to="/" />} />
|
|
</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; |