Add dynamic sidebar text color with brand color contrast
- Add sidebar text color picker to Branding Settings page - Implement auto-calculated complementary text colors based on brand color luminance - Dark themes get light tinted text, light themes get dark tinted text - Add navigation preview showing text on gradient background - Support 10 new lighter color palettes (Soft Mint, Lavender, Peach, etc.) - Add CSS utility classes for brand-text with opacity support - Update sidebar and navigation components to use dynamic text colors - Add sidebar_text_color field to Tenant model with migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col h-full text-white shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
|
className={`flex flex-col h-full text-brand-text shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-secondary, ${business.secondaryColor || business.primaryColor}))`
|
background: `linear-gradient(to bottom right, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-secondary, ${business.secondaryColor || business.primaryColor}))`
|
||||||
}}
|
}}
|
||||||
@@ -112,7 +112,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
|
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
||||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
<p className="text-xs text-brand-text/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -331,22 +331,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
<div className="p-4 border-t border-white/10">
|
<div className="p-4 border-t border-brand-text/10">
|
||||||
<a
|
<a
|
||||||
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
|
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`flex items-center gap-2 text-xs text-white/60 mb-3 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
|
className={`flex items-center gap-2 text-xs text-brand-text/60 mb-3 hover:text-brand-text/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
|
||||||
>
|
>
|
||||||
<SmoothScheduleLogo className="w-5 h-5 text-white" />
|
<SmoothScheduleLogo className="w-5 h-5 text-brand-text" />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
|
<span className="text-brand-text/60">{t('nav.smoothSchedule')}</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
disabled={logoutMutation.isPending}
|
disabled={logoutMutation.isPending}
|
||||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/5 w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
|
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium text-brand-text/70 hover:text-brand-text hover:bg-brand-text/5 w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
<LogOut size={18} className="shrink-0" />
|
<LogOut size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('auth.signOut')}</span>}
|
{!isCollapsed && <span>{t('auth.signOut')}</span>}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ export const SidebarSection: React.FC<SidebarSectionProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={`space-y-1 ${className}`}>
|
<div className={`space-y-1 ${className}`}>
|
||||||
{title && !isCollapsed && (
|
{title && !isCollapsed && (
|
||||||
<h3 className="px-4 pt-1 pb-1.5 text-xs font-semibold uppercase tracking-wider text-white/40">
|
<h3 className="px-4 pt-1 pb-1.5 text-xs font-semibold uppercase tracking-wider text-brand-text/40">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
{title && isCollapsed && (
|
{title && isCollapsed && (
|
||||||
<div className="mx-auto w-8 border-t border-white/20 my-2" />
|
<div className="mx-auto w-8 border-t border-brand-text/20 my-2" />
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,14 +83,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
|||||||
? '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-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'
|
: '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
|
: isActive
|
||||||
? 'bg-white/10 text-white'
|
? 'bg-brand-text/10 text-brand-text'
|
||||||
: locked
|
: locked
|
||||||
? 'text-white/40 hover:text-white/60 hover:bg-white/5'
|
? 'text-brand-text/40 hover:text-brand-text/60 hover:bg-brand-text/5'
|
||||||
: 'text-white/70 hover:text-white hover:bg-white/5';
|
: 'text-brand-text/70 hover:text-brand-text hover:bg-brand-text/5';
|
||||||
|
|
||||||
const disabledClasses = variant === 'settings'
|
const disabledClasses = variant === 'settings'
|
||||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||||
: 'text-white/30 cursor-not-allowed';
|
: 'text-brand-text/30 cursor-not-allowed';
|
||||||
|
|
||||||
const className = `${baseClasses} ${collapsedClasses} ${disabled ? disabledClasses : colorClasses}`;
|
const className = `${baseClasses} ${collapsedClasses} ${disabled ? disabledClasses : colorClasses}`;
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
|||||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||||
{(badge || badgeElement) && !isCollapsed && (
|
{(badge || badgeElement) && !isCollapsed && (
|
||||||
badgeElement || (
|
badgeElement || (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-text/10">{badge}</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,8 +163,8 @@ export const SidebarDropdown: React.FC<SidebarDropdownProps> = ({
|
|||||||
isCollapsed ? 'px-3 justify-center' : 'px-4'
|
isCollapsed ? 'px-3 justify-center' : 'px-4'
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/10 text-white'
|
? 'bg-brand-text/10 text-brand-text'
|
||||||
: 'text-white/70 hover:text-white hover:bg-white/5'
|
: 'text-brand-text/70 hover:text-brand-text hover:bg-brand-text/5'
|
||||||
}`}
|
}`}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
@@ -180,7 +180,7 @@ export const SidebarDropdown: React.FC<SidebarDropdownProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && !isCollapsed && (
|
{isOpen && !isCollapsed && (
|
||||||
<div className="ml-4 mt-1 space-y-0.5 border-l border-white/20 pl-4">
|
<div className="ml-4 mt-1 space-y-0.5 border-l border-brand-text/20 pl-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,8 +210,8 @@ export const SidebarSubItem: React.FC<SidebarSubItemProps> = ({
|
|||||||
to={to}
|
to={to}
|
||||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${
|
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/10 text-white'
|
? 'bg-brand-text/10 text-brand-text'
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
: 'text-brand-text/60 hover:text-brand-text hover:bg-brand-text/5'
|
||||||
}`}
|
}`}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
@@ -230,7 +230,7 @@ interface SidebarDividerProps {
|
|||||||
*/
|
*/
|
||||||
export const SidebarDivider: React.FC<SidebarDividerProps> = ({ isCollapsed }) => {
|
export const SidebarDivider: React.FC<SidebarDividerProps> = ({ isCollapsed }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`my-4 ${isCollapsed ? 'mx-3' : 'mx-4'} border-t border-white/10`} />
|
<div className={`my-4 ${isCollapsed ? 'mx-3' : 'mx-4'} border-t border-brand-text/10`} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantClasses: Record<ButtonVariant, string> = {
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
|
primary: 'bg-brand-600 hover:bg-brand-700 text-brand-text border-transparent',
|
||||||
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
|
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
|
||||||
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
|
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
|
||||||
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
|
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const colorClasses = {
|
|||||||
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
active: 'bg-brand-600 text-white',
|
active: 'bg-brand-600 text-brand-text',
|
||||||
completed: 'bg-brand-600 text-white',
|
completed: 'bg-brand-600 text-brand-text',
|
||||||
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||||
textActive: 'text-brand-600 dark:text-brand-400',
|
textActive: 'text-brand-600 dark:text-brand-400',
|
||||||
textPending: 'text-gray-400',
|
textPending: 'text-gray-400',
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const activeColorClasses = {
|
|||||||
underline: 'border-green-600 text-green-600 dark:text-green-400',
|
underline: 'border-green-600 text-green-600 dark:text-green-400',
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
active: 'bg-brand-600 text-white',
|
active: 'bg-brand-600 text-brand-text',
|
||||||
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
|
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
|
||||||
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
|
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,12 +15,85 @@
|
|||||||
--color-brand-700: #1d4ed8;
|
--color-brand-700: #1d4ed8;
|
||||||
--color-brand-800: #1e40af;
|
--color-brand-800: #1e40af;
|
||||||
--color-brand-900: #1e3a8a;
|
--color-brand-900: #1e3a8a;
|
||||||
|
|
||||||
|
/* Dynamic brand text color - uses RGB for opacity support */
|
||||||
|
/* Format: rgb(R G B / alpha) where R G B come from --color-brand-text-rgb */
|
||||||
|
--color-brand-text: rgb(var(--color-brand-text-rgb) / 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
/* Default brand text color (light blue-white for dark backgrounds) */
|
||||||
|
/* This is dynamically updated by applyBrandColors() based on brand color luminance */
|
||||||
|
--color-brand-text-rgb: 233 239 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom text-brand-text utility classes with opacity support */
|
||||||
|
.text-brand-text {
|
||||||
|
color: rgb(var(--color-brand-text-rgb));
|
||||||
|
}
|
||||||
|
.text-brand-text\/5 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.05);
|
||||||
|
}
|
||||||
|
.text-brand-text\/10 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.1);
|
||||||
|
}
|
||||||
|
.text-brand-text\/20 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.2);
|
||||||
|
}
|
||||||
|
.text-brand-text\/30 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.3);
|
||||||
|
}
|
||||||
|
.text-brand-text\/40 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.4);
|
||||||
|
}
|
||||||
|
.text-brand-text\/50 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.5);
|
||||||
|
}
|
||||||
|
.text-brand-text\/60 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.6);
|
||||||
|
}
|
||||||
|
.text-brand-text\/70 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.7);
|
||||||
|
}
|
||||||
|
.text-brand-text\/80 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.8);
|
||||||
|
}
|
||||||
|
.text-brand-text\/90 {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover variants */
|
||||||
|
.hover\:text-brand-text:hover {
|
||||||
|
color: rgb(var(--color-brand-text-rgb));
|
||||||
|
}
|
||||||
|
.hover\:text-brand-text\/60:hover {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.6);
|
||||||
|
}
|
||||||
|
.hover\:text-brand-text\/80:hover {
|
||||||
|
color: rgb(var(--color-brand-text-rgb) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background variants using brand-text color */
|
||||||
|
.bg-brand-text\/5 {
|
||||||
|
background-color: rgb(var(--color-brand-text-rgb) / 0.05);
|
||||||
|
}
|
||||||
|
.bg-brand-text\/10 {
|
||||||
|
background-color: rgb(var(--color-brand-text-rgb) / 0.1);
|
||||||
|
}
|
||||||
|
.hover\:bg-brand-text\/5:hover {
|
||||||
|
background-color: rgb(var(--color-brand-text-rgb) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border variants */
|
||||||
|
.border-brand-text\/10 {
|
||||||
|
border-color: rgb(var(--color-brand-text-rgb) / 0.1);
|
||||||
|
}
|
||||||
|
.border-brand-text\/20 {
|
||||||
|
border-color: rgb(var(--color-brand-text-rgb) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
|
|||||||
@@ -66,17 +66,19 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
|||||||
setTicketModalId(null);
|
setTicketModalId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set CSS custom properties for brand colors (primary palette + secondary color)
|
// Set CSS custom properties for brand colors (primary palette + secondary color + sidebar text)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyBrandColors(
|
applyBrandColors(
|
||||||
business.primaryColor || '#2563eb',
|
business.primaryColor || '#2563eb',
|
||||||
business.secondaryColor || business.primaryColor || '#2563eb'
|
business.secondaryColor || business.primaryColor || '#2563eb',
|
||||||
|
business.sidebarTextColor // Optional custom sidebar text color
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup: reset to defaults when component unmounts
|
// Cleanup: reset to defaults when component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
applyColorPalette(defaultColorPalette);
|
applyColorPalette(defaultColorPalette);
|
||||||
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
|
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
|
||||||
|
document.documentElement.style.setProperty('--color-brand-text-rgb', '255 255 255');
|
||||||
};
|
};
|
||||||
}, [business.primaryColor, business.secondaryColor]);
|
}, [business.primaryColor, business.secondaryColor]);
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const BookingPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<button onClick={handleConfirmBooking} className="w-full max-w-xs py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">
|
<button onClick={handleConfirmBooking} className="w-full max-w-xs py-3 bg-brand-600 text-brand-text font-semibold rounded-lg hover:bg-brand-700">
|
||||||
Confirm Appointment
|
Confirm Appointment
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +242,7 @@ const BookingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pt-4 flex justify-center gap-4">
|
<div className="pt-4 flex justify-center gap-4">
|
||||||
<Link to="/" className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">Go to Dashboard</Link>
|
<Link to="/" className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">Go to Dashboard</Link>
|
||||||
<button onClick={resetFlow} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700">Book Another</button>
|
<button onClick={resetFlow} className="px-4 py-2 bg-brand-600 text-brand-text rounded-lg hover:bg-brand-700">Book Another</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,22 +10,34 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
|
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
|
||||||
import { Business, User } from '../../types';
|
import { Business, User } from '../../types';
|
||||||
import { applyBrandColors } from '../../utils/colorUtils';
|
import { applyBrandColors, getContrastTextColor } from '../../utils/colorUtils';
|
||||||
import { UpgradePrompt } from '../../components/UpgradePrompt';
|
import { UpgradePrompt } from '../../components/UpgradePrompt';
|
||||||
import { FeatureKey } from '../../hooks/usePlanFeatures';
|
import { FeatureKey } from '../../hooks/usePlanFeatures';
|
||||||
|
|
||||||
// Color palette options
|
// Color palette options - organized by type
|
||||||
const colorPalettes = [
|
const colorPalettes = [
|
||||||
|
// Bold/Dark themes (white text)
|
||||||
{ name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' },
|
{ name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' },
|
||||||
{ name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' },
|
{ name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' },
|
||||||
{ name: 'Mint Green', primary: '#10b981', secondary: '#34d399' },
|
{ name: 'Emerald', primary: '#10b981', secondary: '#34d399' },
|
||||||
{ name: 'Coral Reef', primary: '#f97316', secondary: '#fb923c' },
|
{ name: 'Coral', primary: '#f97316', secondary: '#fb923c' },
|
||||||
{ name: 'Lavender', primary: '#a78bfa', secondary: '#c4b5fd' },
|
{ name: 'Rose', primary: '#ec4899', secondary: '#f472b6' },
|
||||||
{ name: 'Rose Pink', primary: '#ec4899', secondary: '#f472b6' },
|
{ name: 'Forest', primary: '#059669', secondary: '#10b981' },
|
||||||
{ name: 'Forest Green', primary: '#059669', secondary: '#10b981' },
|
{ name: 'Violet', primary: '#7c3aed', secondary: '#a78bfa' },
|
||||||
{ name: 'Royal Purple', primary: '#7c3aed', secondary: '#a78bfa' },
|
{ name: 'Slate', primary: '#475569', secondary: '#64748b' },
|
||||||
{ name: 'Slate Gray', primary: '#475569', secondary: '#64748b' },
|
{ name: 'Crimson', primary: '#dc2626', secondary: '#ef4444' },
|
||||||
{ name: 'Crimson Red', primary: '#dc2626', secondary: '#ef4444' },
|
{ name: 'Indigo', primary: '#4f46e5', secondary: '#6366f1' },
|
||||||
|
// Light/Pastel themes (dark text)
|
||||||
|
{ name: 'Soft Mint', primary: '#6ee7b7', secondary: '#a7f3d0' },
|
||||||
|
{ name: 'Lavender', primary: '#c4b5fd', secondary: '#ddd6fe' },
|
||||||
|
{ name: 'Peach', primary: '#fdba74', secondary: '#fed7aa' },
|
||||||
|
{ name: 'Baby Blue', primary: '#7dd3fc', secondary: '#bae6fd' },
|
||||||
|
{ name: 'Blush', primary: '#fda4af', secondary: '#fecdd3' },
|
||||||
|
{ name: 'Lemon', primary: '#fde047', secondary: '#fef08a' },
|
||||||
|
{ name: 'Aqua', primary: '#5eead4', secondary: '#99f6e4' },
|
||||||
|
{ name: 'Lilac', primary: '#d8b4fe', secondary: '#e9d5ff' },
|
||||||
|
{ name: 'Apricot', primary: '#fcd34d', secondary: '#fde68a' },
|
||||||
|
{ name: 'Cloud', primary: '#94a3b8', secondary: '#cbd5e1' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BrandingSettings: React.FC = () => {
|
const BrandingSettings: React.FC = () => {
|
||||||
@@ -38,12 +50,19 @@ const BrandingSettings: React.FC = () => {
|
|||||||
lockedFeature?: FeatureKey;
|
lockedFeature?: FeatureKey;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// Calculate initial text color (use saved value or auto-calculate)
|
||||||
|
const getInitialTextColor = () => {
|
||||||
|
if (business.sidebarTextColor) return business.sidebarTextColor;
|
||||||
|
return getContrastTextColor(business.primaryColor, business.primaryColor);
|
||||||
|
};
|
||||||
|
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
logoUrl: business.logoUrl,
|
logoUrl: business.logoUrl,
|
||||||
emailLogoUrl: business.emailLogoUrl,
|
emailLogoUrl: business.emailLogoUrl,
|
||||||
logoDisplayMode: business.logoDisplayMode || 'text-only',
|
logoDisplayMode: business.logoDisplayMode || 'text-only',
|
||||||
primaryColor: business.primaryColor,
|
primaryColor: business.primaryColor,
|
||||||
secondaryColor: business.secondaryColor || business.primaryColor,
|
secondaryColor: business.secondaryColor || business.primaryColor,
|
||||||
|
sidebarTextColor: getInitialTextColor(),
|
||||||
});
|
});
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
@@ -51,25 +70,36 @@ const BrandingSettings: React.FC = () => {
|
|||||||
const savedColorsRef = useRef({
|
const savedColorsRef = useRef({
|
||||||
primary: business.primaryColor,
|
primary: business.primaryColor,
|
||||||
secondary: business.secondaryColor || business.primaryColor,
|
secondary: business.secondaryColor || business.primaryColor,
|
||||||
|
sidebarText: getInitialTextColor(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live preview: Update CSS variables as user cycles through palettes
|
// Live preview: Update CSS variables as user cycles through palettes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyBrandColors(formState.primaryColor, formState.secondaryColor);
|
applyBrandColors(
|
||||||
|
formState.primaryColor,
|
||||||
|
formState.secondaryColor,
|
||||||
|
formState.sidebarTextColor || undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup: Restore saved colors when component unmounts (navigation away)
|
// Cleanup: Restore saved colors when component unmounts (navigation away)
|
||||||
return () => {
|
return () => {
|
||||||
applyBrandColors(savedColorsRef.current.primary, savedColorsRef.current.secondary);
|
applyBrandColors(
|
||||||
|
savedColorsRef.current.primary,
|
||||||
|
savedColorsRef.current.secondary,
|
||||||
|
savedColorsRef.current.sidebarText || undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [formState.primaryColor, formState.secondaryColor]);
|
}, [formState.primaryColor, formState.secondaryColor, formState.sidebarTextColor]);
|
||||||
|
|
||||||
// Update savedColorsRef when business data changes (after successful save)
|
// Update savedColorsRef when business data changes (after successful save)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const textColor = business.sidebarTextColor || getContrastTextColor(business.primaryColor, business.primaryColor);
|
||||||
savedColorsRef.current = {
|
savedColorsRef.current = {
|
||||||
primary: business.primaryColor,
|
primary: business.primaryColor,
|
||||||
secondary: business.secondaryColor || business.primaryColor,
|
secondary: business.secondaryColor || business.primaryColor,
|
||||||
|
sidebarText: textColor,
|
||||||
};
|
};
|
||||||
}, [business.primaryColor, business.secondaryColor]);
|
}, [business.primaryColor, business.secondaryColor, business.sidebarTextColor]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await updateBusiness(formState);
|
await updateBusiness(formState);
|
||||||
@@ -77,13 +107,35 @@ const BrandingSettings: React.FC = () => {
|
|||||||
savedColorsRef.current = {
|
savedColorsRef.current = {
|
||||||
primary: formState.primaryColor,
|
primary: formState.primaryColor,
|
||||||
secondary: formState.secondaryColor,
|
secondary: formState.secondaryColor,
|
||||||
|
sidebarText: formState.sidebarTextColor,
|
||||||
};
|
};
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
setTimeout(() => setShowToast(false), 3000);
|
setTimeout(() => setShowToast(false), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate the effective text color (custom or auto-calculated)
|
||||||
|
const getEffectiveTextColor = () => {
|
||||||
|
if (formState.sidebarTextColor) {
|
||||||
|
return formState.sidebarTextColor;
|
||||||
|
}
|
||||||
|
return getContrastTextColor(formState.primaryColor, formState.primaryColor);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update sidebar text color to the auto-calculated value
|
||||||
|
const resetToAutoTextColor = () => {
|
||||||
|
const autoColor = getContrastTextColor(formState.primaryColor, formState.primaryColor);
|
||||||
|
setFormState(prev => ({ ...prev, sidebarTextColor: autoColor }));
|
||||||
|
};
|
||||||
|
|
||||||
const selectPalette = (primary: string, secondary: string) => {
|
const selectPalette = (primary: string, secondary: string) => {
|
||||||
setFormState(prev => ({ ...prev, primaryColor: primary, secondaryColor: secondary }));
|
// When selecting a palette, auto-calculate the text color for it
|
||||||
|
const autoTextColor = getContrastTextColor(primary, primary);
|
||||||
|
setFormState(prev => ({
|
||||||
|
...prev,
|
||||||
|
primaryColor: primary,
|
||||||
|
secondaryColor: secondary,
|
||||||
|
sidebarTextColor: autoTextColor,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
@@ -285,7 +337,7 @@ const BrandingSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Colors */}
|
{/* Custom Colors */}
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex flex-wrap items-start gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Primary Color
|
Primary Color
|
||||||
@@ -324,14 +376,73 @@ const BrandingSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Preview
|
Navigation Text
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={getEffectiveTextColor()}
|
||||||
|
onChange={(e) => setFormState(prev => ({ ...prev, sidebarTextColor: e.target.value }))}
|
||||||
|
className="w-10 h-10 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getEffectiveTextColor()}
|
||||||
|
onChange={(e) => setFormState(prev => ({ ...prev, sidebarTextColor: e.target.value }))}
|
||||||
|
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={resetToAutoTextColor}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded"
|
||||||
|
title="Reset to auto-calculated color"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Preview */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Navigation Preview
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className="h-10 rounded-lg"
|
className="rounded-lg p-4 space-y-3"
|
||||||
style={{ background: `linear-gradient(to right, ${formState.primaryColor}, ${formState.secondaryColor})` }}
|
style={{ background: `linear-gradient(to bottom right, ${formState.primaryColor}, ${formState.secondaryColor})` }}
|
||||||
/>
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.9)', color: formState.primaryColor }}
|
||||||
|
>
|
||||||
|
{business.name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-brand-text">{business.name}</p>
|
||||||
|
<p className="text-xs text-brand-text/60">{business.subdomain}.smoothschedule.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-brand-text/10 pt-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-brand-text/70 hover:text-brand-text hover:bg-brand-text/5 text-sm">
|
||||||
|
<span className="w-4 h-4 bg-brand-text/20 rounded" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded bg-brand-text/10 text-brand-text text-sm font-medium">
|
||||||
|
<span className="w-4 h-4 bg-brand-text/30 rounded" />
|
||||||
|
<span>Scheduler</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-brand-text/70 hover:text-brand-text hover:bg-brand-text/5 text-sm">
|
||||||
|
<span className="w-4 h-4 bg-brand-text/20 rounded" />
|
||||||
|
<span>Customers</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-brand-text/40 text-sm">
|
||||||
|
<span className="w-4 h-4 bg-brand-text/10 rounded" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -340,7 +451,7 @@ const BrandingSettings: React.FC = () => {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors"
|
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-brand-text font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface Business {
|
|||||||
subdomain: string;
|
subdomain: string;
|
||||||
primaryColor: string;
|
primaryColor: string;
|
||||||
secondaryColor: string;
|
secondaryColor: string;
|
||||||
|
sidebarTextColor?: string; // Custom sidebar/nav text color (auto-calculated if not set)
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
emailLogoUrl?: string;
|
emailLogoUrl?: string;
|
||||||
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
|
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
|
||||||
|
|||||||
@@ -2,6 +2,80 @@
|
|||||||
* Color utility functions for generating brand color palettes
|
* Color utility functions for generating brand color palettes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex color to RGB values
|
||||||
|
*/
|
||||||
|
export function hexToRGB(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
if (!result) return { r: 0, g: 0, b: 0 };
|
||||||
|
return {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex color to RGB string for Tailwind CSS variable opacity support
|
||||||
|
* Returns "R G B" format (space-separated) for use with rgb() and opacity
|
||||||
|
*/
|
||||||
|
export function hexToRGBString(hex: string): string {
|
||||||
|
const { r, g, b } = hexToRGB(hex);
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate relative luminance of a color (WCAG formula)
|
||||||
|
* Returns value between 0 (black) and 1 (white)
|
||||||
|
*/
|
||||||
|
export function getLuminance(hex: string): number {
|
||||||
|
const { r, g, b } = hexToRGB(hex);
|
||||||
|
const sR = r / 255;
|
||||||
|
const sG = g / 255;
|
||||||
|
const sB = b / 255;
|
||||||
|
const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
|
||||||
|
const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
|
||||||
|
const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
|
||||||
|
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a dark complementary text color from a brand color
|
||||||
|
* Creates a very dark shade of the brand's hue for use on light backgrounds
|
||||||
|
*/
|
||||||
|
export function getDarkBrandTextColor(brandColor: string): string {
|
||||||
|
const { h, s } = hexToHSL(brandColor);
|
||||||
|
// Create a very dark, moderately saturated version of the brand color
|
||||||
|
// Lightness ~12% for strong contrast, saturation capped for readability
|
||||||
|
return hslToHex(h, Math.min(s, 50), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a light complementary text color from a brand color
|
||||||
|
* Creates a very light shade of the brand's hue for use on dark backgrounds
|
||||||
|
*/
|
||||||
|
export function getLightBrandTextColor(brandColor: string): string {
|
||||||
|
const { h, s } = hexToHSL(brandColor);
|
||||||
|
// Create a very light, soft version of the brand color
|
||||||
|
// Lightness ~92% for good contrast, lower saturation for softness
|
||||||
|
return hslToHex(h, Math.min(s, 40), 92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate text color for optimal contrast on a background
|
||||||
|
* Uses WCAG 2.1 contrast ratio guidelines
|
||||||
|
* Returns complementary brand shades instead of pure black/white
|
||||||
|
*/
|
||||||
|
export function getContrastTextColor(backgroundColor: string, brandColor?: string): string {
|
||||||
|
const luminance = getLuminance(backgroundColor);
|
||||||
|
if (luminance > 0.179) {
|
||||||
|
// Light background - use dark brand shade
|
||||||
|
return brandColor ? getDarkBrandTextColor(brandColor) : '#1f2937';
|
||||||
|
}
|
||||||
|
// Dark background - use light brand shade
|
||||||
|
return brandColor ? getLightBrandTextColor(brandColor) : '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert hex color to HSL values
|
* Convert hex color to HSL values
|
||||||
*/
|
*/
|
||||||
@@ -94,15 +168,34 @@ export function applyColorPalette(palette: Record<string, string>): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply primary and secondary colors including the secondary color variable
|
* Apply primary and secondary colors including contrast text colors
|
||||||
|
* @param primaryColor - The main brand color
|
||||||
|
* @param secondaryColor - Optional secondary color for gradients
|
||||||
|
* @param sidebarTextColor - Optional custom sidebar text color (auto-calculated if not provided)
|
||||||
*/
|
*/
|
||||||
export function applyBrandColors(primaryColor: string, secondaryColor?: string): void {
|
export function applyBrandColors(primaryColor: string, secondaryColor?: string, sidebarTextColor?: string): void {
|
||||||
const palette = generateColorPalette(primaryColor);
|
const palette = generateColorPalette(primaryColor);
|
||||||
applyColorPalette(palette);
|
applyColorPalette(palette);
|
||||||
|
|
||||||
// Set the secondary color variable (used for gradients)
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Set the secondary color variable (used for gradients)
|
||||||
root.style.setProperty('--color-brand-secondary', secondaryColor || primaryColor);
|
root.style.setProperty('--color-brand-secondary', secondaryColor || primaryColor);
|
||||||
|
|
||||||
|
// Set contrast text colors for each shade level
|
||||||
|
// These ensure readable text on brand-colored backgrounds
|
||||||
|
// Pass primaryColor so dark text is a complementary shade, not generic black
|
||||||
|
Object.entries(palette).forEach(([shade, color]) => {
|
||||||
|
const textColor = getContrastTextColor(color, primaryColor);
|
||||||
|
root.style.setProperty(`--color-brand-${shade}-text`, textColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set main brand text color (for use with brand-600 backgrounds like sidebar)
|
||||||
|
// Use custom sidebarTextColor if provided, otherwise auto-calculate based on contrast
|
||||||
|
const brandTextColor = sidebarTextColor || getContrastTextColor(palette['600'], primaryColor);
|
||||||
|
root.style.setProperty('--color-brand-text', brandTextColor);
|
||||||
|
// Also set RGB version for Tailwind opacity support (e.g., text-brand-text/70)
|
||||||
|
root.style.setProperty('--color-brand-text-rgb', hexToRGBString(brandTextColor));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default {
|
|||||||
800: 'var(--color-brand-800, #1e40af)',
|
800: 'var(--color-brand-800, #1e40af)',
|
||||||
900: 'var(--color-brand-900, #1e3a8a)',
|
900: 'var(--color-brand-900, #1e3a8a)',
|
||||||
},
|
},
|
||||||
|
// Contrast text color for brand backgrounds (supports opacity modifiers like /70)
|
||||||
|
'brand-text': 'rgb(var(--color-brand-text-rgb, 255 255 255) / <alpha-value>)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-17 03:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0029_tenant_block_emails'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='sidebar_text_color',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Custom sidebar text color (hex format). Leave empty for auto-calculated contrast.', max_length=7),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -54,6 +54,12 @@ class Tenant(TenantMixin):
|
|||||||
default='#0ea5e9',
|
default='#0ea5e9',
|
||||||
help_text="Secondary brand color (hex format, e.g., #0ea5e9)"
|
help_text="Secondary brand color (hex format, e.g., #0ea5e9)"
|
||||||
)
|
)
|
||||||
|
sidebar_text_color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Custom sidebar text color (hex format). Leave empty for auto-calculated contrast."
|
||||||
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
contact_email = models.EmailField(blank=True)
|
contact_email = models.EmailField(blank=True)
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ def current_business_view(request):
|
|||||||
# Branding fields from Tenant model
|
# Branding fields from Tenant model
|
||||||
'primary_color': tenant.primary_color,
|
'primary_color': tenant.primary_color,
|
||||||
'secondary_color': tenant.secondary_color,
|
'secondary_color': tenant.secondary_color,
|
||||||
|
'sidebar_text_color': tenant.sidebar_text_color or None,
|
||||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||||
'logo_display_mode': tenant.logo_display_mode,
|
'logo_display_mode': tenant.logo_display_mode,
|
||||||
@@ -260,6 +261,9 @@ def update_business_view(request):
|
|||||||
if 'secondary_color' in request.data:
|
if 'secondary_color' in request.data:
|
||||||
tenant.secondary_color = request.data['secondary_color']
|
tenant.secondary_color = request.data['secondary_color']
|
||||||
|
|
||||||
|
if 'sidebar_text_color' in request.data:
|
||||||
|
tenant.sidebar_text_color = request.data['sidebar_text_color'] or ''
|
||||||
|
|
||||||
if 'logo_display_mode' in request.data:
|
if 'logo_display_mode' in request.data:
|
||||||
tenant.logo_display_mode = request.data['logo_display_mode']
|
tenant.logo_display_mode = request.data['logo_display_mode']
|
||||||
|
|
||||||
@@ -340,6 +344,7 @@ def update_business_view(request):
|
|||||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||||
'primary_color': tenant.primary_color,
|
'primary_color': tenant.primary_color,
|
||||||
'secondary_color': tenant.secondary_color,
|
'secondary_color': tenant.secondary_color,
|
||||||
|
'sidebar_text_color': tenant.sidebar_text_color or None,
|
||||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||||
'logo_display_mode': tenant.logo_display_mode,
|
'logo_display_mode': tenant.logo_display_mode,
|
||||||
|
|||||||
Reference in New Issue
Block a user