Files
smoothschedule/frontend/src/components/navigation/SidebarComponents.tsx
poduck 5cef01ad0d feat: Reorganize settings sidebar and add plan-based feature locking
- 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>
2025-12-03 01:35:59 -05:00

302 lines
7.9 KiB
TypeScript

/**
* Shared Sidebar Navigation Components
*
* Reusable building blocks for main sidebar and settings sidebar navigation.
*/
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronDown, Lock, LucideIcon } from 'lucide-react';
interface SidebarSectionProps {
title?: string;
children: React.ReactNode;
isCollapsed?: boolean;
className?: string;
}
/**
* Section wrapper with optional header
*/
export const SidebarSection: React.FC<SidebarSectionProps> = ({
title,
children,
isCollapsed = false,
className = '',
}) => {
return (
<div className={`space-y-1 ${className}`}>
{title && !isCollapsed && (
<h3 className="px-4 pt-1 pb-1.5 text-xs font-semibold uppercase tracking-wider text-white/40">
{title}
</h3>
)}
{title && isCollapsed && (
<div className="mx-auto w-8 border-t border-white/20 my-2" />
)}
{children}
</div>
);
};
interface SidebarItemProps {
to: string;
icon: LucideIcon;
label: string;
isCollapsed?: boolean;
exact?: boolean;
disabled?: boolean;
badge?: string | number;
variant?: 'default' | 'settings';
locked?: boolean;
}
/**
* Navigation item with icon
*/
export const SidebarItem: React.FC<SidebarItemProps> = ({
to,
icon: Icon,
label,
isCollapsed = false,
exact = false,
disabled = false,
badge,
variant = 'default',
locked = false,
}) => {
const location = useLocation();
const isActive = exact
? location.pathname === to
: location.pathname.startsWith(to);
const baseClasses = 'flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors';
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
// Different color schemes for main nav vs settings nav
const colorClasses = variant === 'settings'
? isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
: locked
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
: isActive
? 'bg-white/10 text-white'
: locked
? 'text-white/40 hover:text-white/60 hover:bg-white/5'
: 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = variant === 'settings'
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-white/30 cursor-not-allowed';
const className = `${baseClasses} ${collapsedClasses} ${disabled ? disabledClasses : colorClasses}`;
if (disabled) {
return (
<div className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
)}
</div>
);
}
return (
<Link to={to} className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && (
<span className="flex-1 flex items-center gap-1.5">
{label}
{locked && <Lock size={12} className="opacity-60" />}
</span>
)}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
)}
</Link>
);
};
interface SidebarDropdownProps {
icon: LucideIcon;
label: string;
children: React.ReactNode;
isCollapsed?: boolean;
defaultOpen?: boolean;
isActiveWhen?: string[];
}
/**
* Collapsible dropdown section
*/
export const SidebarDropdown: React.FC<SidebarDropdownProps> = ({
icon: Icon,
label,
children,
isCollapsed = false,
defaultOpen = false,
isActiveWhen = [],
}) => {
const location = useLocation();
const [isOpen, setIsOpen] = React.useState(
defaultOpen || isActiveWhen.some(path => location.pathname.startsWith(path))
);
const isActive = isActiveWhen.some(path => location.pathname.startsWith(path));
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors w-full ${
isCollapsed ? 'px-3 justify-center' : 'px-4'
} ${
isActive
? 'bg-white/10 text-white'
: 'text-white/70 hover:text-white hover:bg-white/5'
}`}
title={label}
>
<Icon size={20} className="shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1 text-left">{label}</span>
<ChevronDown
size={16}
className={`shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</>
)}
</button>
{isOpen && !isCollapsed && (
<div className="ml-4 mt-1 space-y-0.5 border-l border-white/20 pl-4">
{children}
</div>
)}
</div>
);
};
interface SidebarSubItemProps {
to: string;
icon: LucideIcon;
label: string;
}
/**
* Sub-item for dropdown menus
*/
export const SidebarSubItem: React.FC<SidebarSubItemProps> = ({
to,
icon: Icon,
label,
}) => {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link
to={to}
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${
isActive
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
}`}
title={label}
>
<Icon size={16} className="shrink-0" />
<span>{label}</span>
</Link>
);
};
interface SidebarDividerProps {
isCollapsed?: boolean;
}
/**
* Visual divider between sections
*/
export const SidebarDivider: React.FC<SidebarDividerProps> = ({ isCollapsed }) => {
return (
<div className={`my-4 ${isCollapsed ? 'mx-3' : 'mx-4'} border-t border-white/10`} />
);
};
interface SettingsSidebarSectionProps {
title: string;
children: React.ReactNode;
}
/**
* Section for settings sidebar (different styling)
*/
export const SettingsSidebarSection: React.FC<SettingsSidebarSectionProps> = ({
title,
children,
}) => {
return (
<div className="space-y-0.5">
<h3 className="px-4 pt-0.5 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{title}
</h3>
{children}
</div>
);
};
interface SettingsSidebarItemProps {
to: string;
icon: LucideIcon;
label: string;
description?: string;
locked?: boolean;
}
/**
* Settings navigation item with optional description and lock indicator
*/
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
to,
icon: Icon,
label,
description,
locked = false,
}) => {
const location = useLocation();
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
return (
<Link
to={to}
className={`flex items-start gap-2.5 px-4 py-1.5 text-sm rounded-lg transition-colors ${
isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
: locked
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
}`}
>
<Icon size={16} className="shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium">{label}</span>
{locked && (
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
)}
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
{description}
</p>
)}
</div>
</Link>
);
};