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>
This commit is contained in:
@@ -15,88 +15,7 @@ import { useTicket } from '../hooks/useTickets';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
import { SandboxProvider, useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
/**
|
||||
* Convert a hex color to HSL values
|
||||
*/
|
||||
function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
// Remove # if present
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
// Parse hex values
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL values to hex color
|
||||
*/
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
|
||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a color palette from a base color
|
||||
*/
|
||||
function generateColorPalette(baseColor: string): Record<string, string> {
|
||||
const { h, s } = hexToHSL(baseColor);
|
||||
|
||||
return {
|
||||
50: hslToHex(h, Math.min(s, 30), 97),
|
||||
100: hslToHex(h, Math.min(s, 40), 94),
|
||||
200: hslToHex(h, Math.min(s, 50), 86),
|
||||
300: hslToHex(h, Math.min(s, 60), 74),
|
||||
400: hslToHex(h, Math.min(s, 70), 60),
|
||||
500: hslToHex(h, s, 50),
|
||||
600: baseColor, // Use the exact primary color for 600
|
||||
700: hslToHex(h, s, 40),
|
||||
800: hslToHex(h, s, 32),
|
||||
900: hslToHex(h, s, 24),
|
||||
};
|
||||
}
|
||||
import { applyColorPalette, applyBrandColors, defaultColorPalette } from '../utils/colorUtils';
|
||||
|
||||
interface BusinessLayoutProps {
|
||||
business: Business;
|
||||
@@ -145,37 +64,19 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
||||
setTicketModalId(null);
|
||||
};
|
||||
|
||||
// Generate brand color palette from business primary color
|
||||
const brandPalette = useMemo(() => {
|
||||
return generateColorPalette(business.primaryColor || '#2563eb');
|
||||
}, [business.primaryColor]);
|
||||
|
||||
// Set CSS custom properties for brand colors
|
||||
// Set CSS custom properties for brand colors (primary palette + secondary color)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(brandPalette).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
applyBrandColors(
|
||||
business.primaryColor || '#2563eb',
|
||||
business.secondaryColor || business.primaryColor || '#2563eb'
|
||||
);
|
||||
|
||||
// Cleanup: reset to defaults when component unmounts
|
||||
return () => {
|
||||
const defaultColors: Record<string, string> = {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
};
|
||||
Object.entries(defaultColors).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
applyColorPalette(defaultColorPalette);
|
||||
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
|
||||
};
|
||||
}, [brandPalette]);
|
||||
}, [business.primaryColor, business.secondaryColor]);
|
||||
|
||||
// Check for trial expiration and redirect
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,14 +19,15 @@ import {
|
||||
Mail,
|
||||
Phone,
|
||||
CreditCard,
|
||||
Webhook,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SettingsSidebarSection,
|
||||
SettingsSidebarItem,
|
||||
} from '../components/navigation/SidebarComponents';
|
||||
import { Business, User } from '../types';
|
||||
import { Business, User, PlanPermissions } from '../types';
|
||||
import { usePlanFeatures, FeatureKey } from '../hooks/usePlanFeatures';
|
||||
|
||||
interface ParentContext {
|
||||
user: User;
|
||||
@@ -34,14 +35,34 @@ interface ParentContext {
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
}
|
||||
|
||||
// Map settings pages to their required plan features
|
||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
||||
'/settings/branding': 'white_label',
|
||||
'/settings/custom-domains': 'custom_domain',
|
||||
'/settings/api': 'api_access',
|
||||
'/settings/authentication': 'custom_oauth',
|
||||
'/settings/sms-calling': 'sms_reminders',
|
||||
};
|
||||
|
||||
const SettingsLayout: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
// Get context from parent route (BusinessLayout)
|
||||
const parentContext = useOutletContext<ParentContext>();
|
||||
|
||||
// Check if a feature is locked (returns true if locked)
|
||||
const isLocked = (feature: FeatureKey | undefined): boolean => {
|
||||
if (!feature) return false;
|
||||
return !canUse(feature);
|
||||
};
|
||||
|
||||
// Get the current page's feature requirement (if any)
|
||||
const currentPageFeature = SETTINGS_PAGE_FEATURES[location.pathname];
|
||||
const currentPageLocked = isLocked(currentPageFeature);
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* Settings Sidebar */}
|
||||
@@ -74,33 +95,52 @@ const SettingsLayout: React.FC = () => {
|
||||
label={t('settings.general.title', 'General')}
|
||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/branding"
|
||||
icon={Palette}
|
||||
label={t('settings.branding.title', 'Branding')}
|
||||
description={t('settings.branding.description', 'Logo, colors, appearance')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/resource-types"
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/booking"
|
||||
icon={Calendar}
|
||||
label={t('settings.booking.title', 'Booking')}
|
||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Branding Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/branding"
|
||||
icon={Palette}
|
||||
label={t('settings.appearance.title', 'Appearance')}
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('white_label')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/email-templates"
|
||||
icon={Mail}
|
||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||
description={t('settings.emailTemplates.description', 'Customize email designs')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/custom-domains"
|
||||
icon={Globe}
|
||||
label={t('settings.customDomains.title', 'Custom Domains')}
|
||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||
locked={isLocked('custom_domain')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Integrations Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/domains"
|
||||
icon={Globe}
|
||||
label={t('settings.domains.title', 'Domains')}
|
||||
description={t('settings.domains.description', 'Custom domain setup')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/api"
|
||||
icon={Key}
|
||||
label={t('settings.api.title', 'API & Webhooks')}
|
||||
description={t('settings.api.description', 'API tokens, webhooks')}
|
||||
locked={isLocked('api_access')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
@@ -111,6 +151,7 @@ const SettingsLayout: React.FC = () => {
|
||||
icon={Lock}
|
||||
label={t('settings.authentication.title', 'Authentication')}
|
||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||
locked={isLocked('custom_oauth')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
@@ -127,6 +168,7 @@ const SettingsLayout: React.FC = () => {
|
||||
icon={Phone}
|
||||
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||
locked={isLocked('sms_reminders')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
@@ -151,7 +193,11 @@ const SettingsLayout: React.FC = () => {
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<Outlet context={parentContext} />
|
||||
<Outlet context={{
|
||||
...parentContext,
|
||||
isFeatureLocked: currentPageLocked,
|
||||
lockedFeature: currentPageFeature,
|
||||
}} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user