Files
smoothschedule/frontend/src/App.tsx

670 lines
25 KiB
TypeScript

/**
* Main App Component - Integrated with Real API
*/
import React, { useState } 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 { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import MFAVerifyPage from './pages/MFAVerifyPage';
import OAuthCallback from './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
import HomePage from './pages/marketing/HomePage';
import FeaturesPage from './pages/marketing/FeaturesPage';
import PricingPage from './pages/marketing/PricingPage';
import AboutPage from './pages/marketing/AboutPage';
import ContactPage from './pages/marketing/ContactPage';
import SignupPage from './pages/marketing/SignupPage';
// Import pages
import Dashboard from './pages/Dashboard';
import Scheduler from './pages/Scheduler';
import Customers from './pages/Customers';
import Settings from './pages/Settings';
import Payments from './pages/Payments';
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';
import Upgrade from './pages/Upgrade';
// Import platform pages
import PlatformDashboard from './pages/platform/PlatformDashboard';
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
import PlatformSupportPage from './pages/platform/PlatformSupport';
import PlatformUsers from './pages/platform/PlatformUsers';
import PlatformStaff from './pages/platform/PlatformStaff';
import PlatformSettings from './pages/platform/PlatformSettings';
import ProfileSettings from './pages/ProfileSettings';
import VerifyEmail from './pages/VerifyEmail';
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 HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentation page
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
import EmailTemplates from './pages/EmailTemplates'; // Import Email Templates page
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();
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// 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);
// Clear session cookie to prevent interference with JWT
// (Django session cookie might take precedence over JWT)
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// 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;
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
};
// 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 (
<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>
<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="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// Not authenticated - show marketing pages
if (!user) {
return (
<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>
<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="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// Error state
if (userError) {
return <ErrorScreen error={userError as Error} />;
}
// Subdomain validation for logged-in users
const currentHostname = window.location.hostname;
const isPlatformDomain = currentHostname === 'platform.lvh.me';
const currentSubdomain = currentHostname.split('.')[0];
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api';
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 must be on platform subdomain (not business subdomains)
if (isPlatformUser && isBusinessSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://platform.lvh.me${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 = `http://${user.business_subdomain}.lvh.me${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 = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${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 (
<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="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
{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>
);
}
// 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 (
<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={<Payments />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// 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) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
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 (
<Routes>
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
</Routes>
);
}
// 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 (
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
);
}
return (
<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 /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<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="/plugins/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/plugins/my-plugins"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
) : (
<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', 'staff']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Services />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
}
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
/>
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// 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;