- Add locked state to Plugins sidebar item with plan feature check - Create Branding section in settings with Appearance, Email Templates, Custom Domains - Split Domains page into Booking (URLs, redirects) and Custom Domains (BYOD, purchase) - Add booking_return_url field to Tenant model for customer redirects - Update SidebarItem component to support locked prop with lock icon - Move Email Templates from main sidebar to Settings > Branding - Add communication credits hooks and payment form updates - Add timezone fields migration and various UI improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
243 lines
8.4 KiB
TypeScript
243 lines
8.4 KiB
TypeScript
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { Outlet, useLocation, useSearchParams, useNavigate } from 'react-router-dom';
|
|
import Sidebar from '../components/Sidebar';
|
|
import TopBar from '../components/TopBar';
|
|
import TrialBanner from '../components/TrialBanner';
|
|
import SandboxBanner from '../components/SandboxBanner';
|
|
import QuotaWarningBanner from '../components/QuotaWarningBanner';
|
|
import { Business, User } from '../types';
|
|
import MasqueradeBanner from '../components/MasqueradeBanner';
|
|
import OnboardingWizard from '../components/OnboardingWizard';
|
|
import TicketModal from '../components/TicketModal';
|
|
import { useStopMasquerade } from '../hooks/useAuth';
|
|
import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket';
|
|
import { useTicket } from '../hooks/useTickets';
|
|
import { MasqueradeStackEntry } from '../api/auth';
|
|
import { useScrollToTop } from '../hooks/useScrollToTop';
|
|
import { SandboxProvider, useSandbox } from '../contexts/SandboxContext';
|
|
import { applyColorPalette, applyBrandColors, defaultColorPalette } from '../utils/colorUtils';
|
|
|
|
interface BusinessLayoutProps {
|
|
business: Business;
|
|
user: User;
|
|
darkMode: boolean;
|
|
toggleTheme: () => void;
|
|
onSignOut: () => void;
|
|
updateBusiness: (updates: Partial<Business>) => void;
|
|
}
|
|
|
|
/**
|
|
* Wrapper component for SandboxBanner that uses the sandbox context
|
|
*/
|
|
const SandboxBannerWrapper: React.FC = () => {
|
|
const { isSandbox, toggleSandbox, isToggling } = useSandbox();
|
|
|
|
return (
|
|
<SandboxBanner
|
|
isSandbox={isSandbox}
|
|
onSwitchToLive={() => toggleSandbox(false)}
|
|
isSwitching={isToggling}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user, darkMode, toggleTheme, onSignOut, updateBusiness }) => {
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
const [ticketModalId, setTicketModalId] = useState<string | null>(null);
|
|
const mainContentRef = useRef<HTMLElement>(null);
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
useScrollToTop();
|
|
|
|
// Fetch ticket data when modal is opened from notification
|
|
const { data: ticketFromNotification } = useTicket(ticketModalId || undefined);
|
|
|
|
const handleTicketClick = (ticketId: string) => {
|
|
setTicketModalId(ticketId);
|
|
};
|
|
|
|
const closeTicketModal = () => {
|
|
setTicketModalId(null);
|
|
};
|
|
|
|
// Set CSS custom properties for brand colors (primary palette + secondary color)
|
|
useEffect(() => {
|
|
applyBrandColors(
|
|
business.primaryColor || '#2563eb',
|
|
business.secondaryColor || business.primaryColor || '#2563eb'
|
|
);
|
|
|
|
// Cleanup: reset to defaults when component unmounts
|
|
return () => {
|
|
applyColorPalette(defaultColorPalette);
|
|
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
|
|
};
|
|
}, [business.primaryColor, business.secondaryColor]);
|
|
|
|
// Check for trial expiration and redirect
|
|
useEffect(() => {
|
|
// Don't check if already on trial-expired page
|
|
if (location.pathname === '/trial-expired') {
|
|
return;
|
|
}
|
|
|
|
// Redirect to trial-expired page if trial has expired
|
|
if (business.isTrialExpired && business.status === 'Trial') {
|
|
navigate('/trial-expired', { replace: true });
|
|
}
|
|
}, [business.isTrialExpired, business.status, location.pathname, navigate]);
|
|
|
|
// Masquerade logic - now using the stack system
|
|
const [masqueradeStack, setMasqueradeStack] = useState<MasqueradeStackEntry[]>([]);
|
|
const stopMasqueradeMutation = useStopMasquerade();
|
|
|
|
useEffect(() => {
|
|
const stackJson = localStorage.getItem('masquerade_stack');
|
|
if (stackJson) {
|
|
try {
|
|
setMasqueradeStack(JSON.parse(stackJson));
|
|
} catch (e) {
|
|
console.error('Failed to parse masquerade stack data', e);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleStopMasquerade = () => {
|
|
stopMasqueradeMutation.mutate();
|
|
};
|
|
|
|
useNotificationWebSocket(); // Activate the notification WebSocket listener
|
|
|
|
// Get the previous user from the stack (the one we'll return to)
|
|
const previousUser = masqueradeStack.length > 0
|
|
? {
|
|
id: masqueradeStack[masqueradeStack.length - 1].user_id,
|
|
username: masqueradeStack[masqueradeStack.length - 1].username,
|
|
name: masqueradeStack[masqueradeStack.length - 1].username,
|
|
role: masqueradeStack[masqueradeStack.length - 1].role,
|
|
email: '',
|
|
is_staff: false,
|
|
is_superuser: false,
|
|
} as User
|
|
: null;
|
|
|
|
// Get the original user (first in the stack)
|
|
const originalUser = masqueradeStack.length > 0
|
|
? {
|
|
id: masqueradeStack[0].user_id,
|
|
username: masqueradeStack[0].username,
|
|
name: masqueradeStack[0].username,
|
|
role: masqueradeStack[0].role,
|
|
email: '',
|
|
is_staff: false,
|
|
is_superuser: false,
|
|
} as User
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
mainContentRef.current?.focus();
|
|
setIsMobileMenuOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
// Check if returning from Stripe Connect onboarding
|
|
useEffect(() => {
|
|
const isOnboardingReturn = searchParams.get('onboarding') === 'true';
|
|
|
|
// Only show onboarding if returning from Stripe Connect
|
|
if (isOnboardingReturn) {
|
|
setShowOnboarding(true);
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const handleOnboardingComplete = () => {
|
|
setShowOnboarding(false);
|
|
// Update local state immediately so wizard doesn't re-appear
|
|
updateBusiness({ initialSetupComplete: true });
|
|
};
|
|
|
|
const handleOnboardingSkip = () => {
|
|
setShowOnboarding(false);
|
|
// If they skip Stripe setup, disable payments
|
|
updateBusiness({ paymentsEnabled: false });
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
|
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
|
|
<Sidebar business={business} user={user} isCollapsed={false} toggleCollapse={() => { }} />
|
|
</div>
|
|
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
|
|
|
|
<div className="hidden md:flex md:flex-shrink-0">
|
|
<Sidebar business={business} user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} />
|
|
</div>
|
|
|
|
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
|
{originalUser && (
|
|
<MasqueradeBanner
|
|
effectiveUser={user}
|
|
originalUser={originalUser}
|
|
previousUser={null}
|
|
onStop={handleStopMasquerade}
|
|
/>
|
|
)}
|
|
{/* Quota overage warning banner - show for owners and managers */}
|
|
{user.quota_overages && user.quota_overages.length > 0 && (
|
|
<QuotaWarningBanner overages={user.quota_overages} />
|
|
)}
|
|
{/* Sandbox mode banner */}
|
|
<SandboxBannerWrapper />
|
|
{/* Show trial banner if trial is active and payments not yet enabled */}
|
|
{business.isTrialActive && !business.paymentsEnabled && business.plan !== 'Free' && (
|
|
<TrialBanner business={business} />
|
|
)}
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={darkMode}
|
|
toggleTheme={toggleTheme}
|
|
onMenuClick={() => setIsMobileMenuOpen(true)}
|
|
onTicketClick={handleTicketClick}
|
|
/>
|
|
|
|
<main ref={mainContentRef} tabIndex={-1} className="flex-1 overflow-auto focus:outline-none">
|
|
{/* Pass all necessary context down to child routes */}
|
|
<Outlet context={{ user, business, updateBusiness }} />
|
|
</main>
|
|
</div>
|
|
|
|
{/* Onboarding wizard for paid-tier businesses */}
|
|
{showOnboarding && (
|
|
<OnboardingWizard
|
|
business={business}
|
|
onComplete={handleOnboardingComplete}
|
|
onSkip={handleOnboardingSkip}
|
|
/>
|
|
)}
|
|
|
|
{/* Ticket modal opened from notification */}
|
|
{ticketModalId && ticketFromNotification && (
|
|
<TicketModal
|
|
ticket={ticketFromNotification}
|
|
onClose={closeTicketModal}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Business Layout with Sandbox Provider
|
|
*/
|
|
const BusinessLayout: React.FC<BusinessLayoutProps> = (props) => {
|
|
return (
|
|
<SandboxProvider>
|
|
<BusinessLayoutContent {...props} />
|
|
</SandboxProvider>
|
|
);
|
|
};
|
|
|
|
export default BusinessLayout; |