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:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View 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;