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:
poduck
2025-12-03 01:35:59 -05:00
parent ef58e9fc94
commit 5cef01ad0d
25 changed files with 2220 additions and 330 deletions

View File

@@ -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(() => {

View File

@@ -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>