Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
167
frontend/src/layouts/BusinessLayout.tsx
Normal file
167
frontend/src/layouts/BusinessLayout.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect, useRef } 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 { Business, User } from '../types';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface BusinessLayoutProps {
|
||||
business: Business;
|
||||
user: User;
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
onSignOut: () => void;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
}
|
||||
|
||||
const BusinessLayout: 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 mainContentRef = useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
// 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}
|
||||
/>
|
||||
)}
|
||||
{/* 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)}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessLayout;
|
||||
103
frontend/src/layouts/CustomerLayout.tsx
Normal file
103
frontend/src/layouts/CustomerLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { User, Business } from '../types';
|
||||
import { LayoutDashboard, CalendarPlus, CreditCard } from 'lucide-react';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import UserProfileDropdown from '../components/UserProfileDropdown';
|
||||
import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface CustomerLayoutProps {
|
||||
business: Business;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const CustomerLayout: React.FC<CustomerLayoutProps> = ({ business, user }) => {
|
||||
useScrollToTop();
|
||||
|
||||
// Masquerade logic
|
||||
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();
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
{originalUser && (
|
||||
<MasqueradeBanner
|
||||
effectiveUser={user}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={handleStopMasquerade}
|
||||
/>
|
||||
)}
|
||||
<header
|
||||
className="text-white shadow-md"
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo and Business Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-white rounded-lg font-bold text-lg" style={{ color: business.primaryColor }}>
|
||||
{business.name.charAt(0)}
|
||||
</div>
|
||||
<span className="font-bold text-lg">{business.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation and User Menu */}
|
||||
<div className="flex items-center gap-6">
|
||||
<nav className="hidden md:flex gap-1">
|
||||
<Link to="/" className="text-sm font-medium text-white/80 hover:text-white transition-colors flex items-center gap-2 px-3 py-2 rounded-md hover:bg-white/10">
|
||||
<LayoutDashboard size={16} /> Dashboard
|
||||
</Link>
|
||||
<Link to="/book" className="text-sm font-medium text-white/80 hover:text-white transition-colors flex items-center gap-2 px-3 py-2 rounded-md hover:bg-white/10">
|
||||
<CalendarPlus size={16} /> Book Appointment
|
||||
</Link>
|
||||
<Link to="/payments" className="text-sm font-medium text-white/80 hover:text-white transition-colors flex items-center gap-2 px-3 py-2 rounded-md hover:bg-white/10">
|
||||
<CreditCard size={16} /> Billing
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<UserProfileDropdown user={user} variant="light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Outlet context={{ business, user }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerLayout;
|
||||
74
frontend/src/layouts/ManagerLayout.tsx
Normal file
74
frontend/src/layouts/ManagerLayout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import PlatformSidebar from '../components/PlatformSidebar';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface ManagerLayoutProps {
|
||||
user: User;
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
const ManagerLayout: React.FC<ManagerLayoutProps> = ({ user, darkMode, toggleTheme, onSignOut }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-100 dark:bg-gray-900">
|
||||
{/* Mobile menu */}
|
||||
<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`}>
|
||||
<PlatformSidebar user={user} isCollapsed={false} toggleCollapse={() => {}} onSignOut={onSignOut} />
|
||||
</div>
|
||||
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<PlatformSidebar user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} onSignOut={onSignOut} />
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="hidden md:flex items-center text-gray-500 dark:text-gray-400 text-sm gap-2">
|
||||
<Globe size={16} />
|
||||
<span>smoothschedule.com</span>
|
||||
<span className="mx-2 text-gray-300">/</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">Management Console</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerLayout;
|
||||
43
frontend/src/layouts/MarketingLayout.tsx
Normal file
43
frontend/src/layouts/MarketingLayout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from '../components/marketing/Navbar';
|
||||
import Footer from '../components/marketing/Footer';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
const MarketingLayout: React.FC = () => {
|
||||
useScrollToTop();
|
||||
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
// Check for saved preference or system preference
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode);
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleTheme = () => setDarkMode((prev: boolean) => !prev);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} />
|
||||
|
||||
{/* Main Content - with padding for fixed navbar */}
|
||||
<main className="flex-1 pt-16 lg:pt-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingLayout;
|
||||
77
frontend/src/layouts/PlatformLayout.tsx
Normal file
77
frontend/src/layouts/PlatformLayout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import PlatformSidebar from '../components/PlatformSidebar';
|
||||
import UserProfileDropdown from '../components/UserProfileDropdown';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface PlatformLayoutProps {
|
||||
user: User;
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleTheme, onSignOut }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* Mobile menu */}
|
||||
<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`}>
|
||||
<PlatformSidebar user={user} isCollapsed={false} toggleCollapse={() => {}} />
|
||||
</div>
|
||||
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<PlatformSidebar user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} />
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* Platform Top Bar */}
|
||||
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="hidden md:flex items-center text-gray-500 dark:text-gray-400 text-sm gap-2">
|
||||
<Globe size={16} />
|
||||
<span>smoothschedule.com</span>
|
||||
<span className="mx-2 text-gray-300">/</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">Admin Console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformLayout;
|
||||
51
frontend/src/layouts/PublicSiteLayout.tsx
Normal file
51
frontend/src/layouts/PublicSiteLayout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Business } from '../types';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface PublicSiteLayoutProps {
|
||||
business: Business;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PublicSiteLayout: React.FC<PublicSiteLayoutProps> = ({ business, children }) => {
|
||||
useScrollToTop();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<header
|
||||
className="shadow-md"
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg font-bold text-xl" style={{ color: business.primaryColor }}>
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-bold text-xl text-white">{business.name}</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
{/* FIX: Property 'websitePages' is optional. Added a check before mapping. */}
|
||||
{business.websitePages && Object.entries(business.websitePages).map(([path, page]) => (
|
||||
<Link key={path} to={path} className="text-sm font-medium text-white/80 hover:text-white transition-colors">{page.name}</Link>
|
||||
))}
|
||||
<Link to="/portal/dashboard" className="px-4 py-2 text-sm font-medium bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">
|
||||
Customer Login
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-gray-100 dark:bg-gray-800 py-6 mt-12">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
© {new Date().getFullYear()} {business.name}. All Rights Reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicSiteLayout;
|
||||
Reference in New Issue
Block a user