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

@@ -1,3 +1,3 @@
VITE_DEV_MODE=true
VITE_API_URL=http://api.lvh.me:8000
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y

View File

@@ -78,7 +78,8 @@ const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
const GeneralSettings = React.lazy(() => import('./pages/settings/GeneralSettings'));
const BrandingSettings = React.lazy(() => import('./pages/settings/BrandingSettings'));
const ResourceTypesSettings = React.lazy(() => import('./pages/settings/ResourceTypesSettings'));
const DomainsSettings = React.lazy(() => import('./pages/settings/DomainsSettings'));
const BookingSettings = React.lazy(() => import('./pages/settings/BookingSettings'));
const CustomDomainsSettings = React.lazy(() => import('./pages/settings/CustomDomainsSettings'));
const ApiSettings = React.lazy(() => import('./pages/settings/ApiSettings'));
const AuthenticationSettings = React.lazy(() => import('./pages/settings/AuthenticationSettings'));
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
@@ -700,7 +701,9 @@ const AppContent: React.FC = () => {
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="domains" element={<DomainsSettings />} />
<Route path="booking" element={<BookingSettings />} />
<Route path="email-templates" element={<EmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
<Route path="authentication" element={<AuthenticationSettings />} />
<Route path="email" element={<EmailSettings />} />

View File

@@ -37,6 +37,7 @@ const PaymentFormInner: React.FC<PaymentFormProps> = ({
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [isElementReady, setIsElementReady] = useState(false);
const confirmPayment = useConfirmPayment();
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
@@ -110,11 +111,20 @@ const PaymentFormInner: React.FC<PaymentFormProps> = ({
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
{!isElementReady && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading payment form...</span>
</div>
)}
<div className={isElementReady ? '' : 'hidden'}>
<PaymentElement
onReady={() => setIsElementReady(true)}
options={{
layout: 'tabs',
}}
/>
</div>
</div>
{errorMessage && (
@@ -164,8 +174,9 @@ interface CreditPaymentModalProps {
onClose: () => void;
onSuccess: () => void;
amountCents: number;
onAmountChange: (cents: number) => void;
onAmountChange?: (cents: number) => void;
savePaymentMethod?: boolean;
skipAmountSelection?: boolean;
}
export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
@@ -175,11 +186,13 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
amountCents,
onAmountChange,
savePaymentMethod = false,
skipAmountSelection = false,
}) => {
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [isLoadingIntent, setIsLoadingIntent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPaymentForm, setShowPaymentForm] = useState(false);
const [autoInitialized, setAutoInitialized] = useState(false);
const createPaymentIntent = useCreatePaymentIntent();
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
@@ -189,9 +202,18 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
setClientSecret(null);
setShowPaymentForm(false);
setError(null);
setAutoInitialized(false);
}
}, [isOpen]);
// Auto-initialize payment when skipping amount selection
useEffect(() => {
if (isOpen && skipAmountSelection && !autoInitialized && !isLoadingIntent && !clientSecret) {
setAutoInitialized(true);
handleContinueToPayment();
}
}, [isOpen, skipAmountSelection, autoInitialized, isLoadingIntent, clientSecret]);
const handleContinueToPayment = async () => {
setIsLoadingIntent(true);
setError(null);
@@ -211,11 +233,19 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Add Credits
</h3>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{skipAmountSelection ? 'Complete Payment' : 'Add Credits'}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{skipAmountSelection
? `Loading ${formatCurrency(amountCents)} to your balance`
: 'Choose an amount to add to your balance'
}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@@ -224,13 +254,46 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
</button>
</div>
{!showPaymentForm ? (
{/* Loading state when auto-initializing */}
{skipAmountSelection && isLoadingIntent && !clientSecret ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
<p className="text-gray-600 dark:text-gray-400">Setting up payment...</p>
</div>
) : skipAmountSelection && error && !clientSecret ? (
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={() => {
setAutoInitialized(false);
setError(null);
}}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
Try Again
</button>
</div>
</div>
) : !showPaymentForm && !skipAmountSelection ? (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick select
</label>
<div className="grid grid-cols-3 gap-3">
{[1000, 2500, 5000].map((amount) => (
<button
key={amount}
onClick={() => onAmountChange(amount)}
onClick={() => onAmountChange?.(amount)}
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
amountCents === amount
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
@@ -255,7 +318,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
value={amountCents / 100}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '');
onAmountChange(Math.max(5, parseInt(val) || 5) * 100);
onAmountChange?.(Math.max(5, parseInt(val) || 5) * 100);
}}
onKeyDown={(e) => {
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {

View File

@@ -14,12 +14,11 @@ import {
Ticket,
HelpCircle,
Clock,
Mail,
Plug,
BookOpen,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import {
SidebarSection,
@@ -38,6 +37,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { t } = useTranslation();
const { role } = user;
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
@@ -52,7 +52,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<div
className={`flex flex-col h-full text-white shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
style={{
background: `linear-gradient(to bottom right, ${business.primaryColor}, ${business.secondaryColor || business.primaryColor})`
background: `linear-gradient(to bottom right, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-secondary, ${business.secondaryColor || business.primaryColor}))`
}}
>
{/* Header / Logo */}
@@ -82,7 +82,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
) : business.logoDisplayMode !== 'logo-only' && (
<div
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg font-bold text-xl shrink-0"
style={{ color: business.primaryColor }}
style={{ color: 'var(--color-brand-600)' }}
>
{business.name.substring(0, 2).toUpperCase()}
</div>
@@ -189,20 +189,15 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
</SidebarSection>
)}
{/* Extend Section - Plugins & Templates */}
{/* Extend Section - Plugins */}
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins"
to="/plugins/marketplace"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/email-templates"
icon={Mail}
label={t('nav.emailTemplates', 'Email Templates')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
/>
</SidebarSection>
)}

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronDown, LucideIcon } from 'lucide-react';
import { ChevronDown, Lock, LucideIcon } from 'lucide-react';
interface SidebarSectionProps {
title?: string;
@@ -48,6 +48,7 @@ interface SidebarItemProps {
disabled?: boolean;
badge?: string | number;
variant?: 'default' | 'settings';
locked?: boolean;
}
/**
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
disabled = false,
badge,
variant = 'default',
locked = false,
}) => {
const location = useLocation();
const isActive = exact
@@ -75,10 +77,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
const colorClasses = variant === 'settings'
? isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
: 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'
: 'text-white/70 hover:text-white hover:bg-white/5';
: 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'
@@ -101,7 +107,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
return (
<Link to={to} className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{!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}
@@ -244,16 +255,18 @@ interface SettingsSidebarItemProps {
icon: LucideIcon;
label: string;
description?: string;
locked?: boolean;
}
/**
* Settings navigation item with optional description
* 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 + '/');
@@ -264,12 +277,19 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
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'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
: 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">
<span className="font-medium">{label}</span>
<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}

View File

@@ -35,6 +35,8 @@ export const useCurrentBusiness = () => {
logoUrl: data.logo_url,
emailLogoUrl: data.email_logo_url,
logoDisplayMode: data.logo_display_mode || 'text-only',
timezone: data.timezone || 'America/New_York',
timezoneDisplayMode: data.timezone_display_mode || 'business',
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
status: data.status,
@@ -82,11 +84,13 @@ export const useUpdateBusiness = () => {
// Map frontend fields to backend fields
if (updates.name) backendData.name = updates.name;
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.primaryColor !== undefined) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor !== undefined) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
if (updates.emailLogoUrl !== undefined) backendData.email_logo_url = updates.emailLogoUrl;
if (updates.logoDisplayMode !== undefined) backendData.logo_display_mode = updates.logoDisplayMode;
if (updates.timezone !== undefined) backendData.timezone = updates.timezone;
if (updates.timezoneDisplayMode !== undefined) backendData.timezone_display_mode = updates.timezoneDisplayMode;
if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled;
}

View File

@@ -193,3 +193,134 @@ export const useCommunicationUsageStats = () => {
staleTime: 60 * 1000, // 1 minute
});
};
// =============================================================================
// Phone Number Management Hooks
// =============================================================================
export interface ProxyPhoneNumber {
id: number;
phone_number: string;
friendly_name: string;
status: 'available' | 'assigned' | 'reserved' | 'inactive';
monthly_fee_cents: number;
capabilities: {
voice: boolean;
sms: boolean;
mms: boolean;
};
assigned_at: string | null;
last_billed_at: string | null;
}
export interface AvailablePhoneNumber {
phone_number: string;
friendly_name: string;
locality: string;
region: string;
postal_code: string;
capabilities: {
voice: boolean;
sms: boolean;
mms: boolean;
};
monthly_cost_cents: number;
}
/**
* Hook to list phone numbers assigned to the tenant
*/
export const usePhoneNumbers = () => {
return useQuery<{ numbers: ProxyPhoneNumber[]; count: number }>({
queryKey: ['phoneNumbers'],
queryFn: async () => {
const { data } = await apiClient.get('/communication-credits/phone-numbers/');
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to search for available phone numbers from Twilio
*/
export const useSearchPhoneNumbers = () => {
return useMutation({
mutationFn: async (params: {
area_code?: string;
contains?: string;
country?: string;
limit?: number;
}) => {
const { data } = await apiClient.get('/communication-credits/phone-numbers/search/', {
params,
});
return data as { numbers: AvailablePhoneNumber[]; count: number };
},
});
};
/**
* Hook to purchase a phone number
*/
export const usePurchasePhoneNumber = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { phone_number: string; friendly_name?: string }) => {
const { data } = await apiClient.post('/communication-credits/phone-numbers/purchase/', params);
return data as {
success: boolean;
phone_number: ProxyPhoneNumber;
balance_cents: number;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['phoneNumbers'] });
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
},
});
};
/**
* Hook to release (delete) a phone number
*/
export const useReleasePhoneNumber = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (numberId: number) => {
const { data } = await apiClient.delete(`/communication-credits/phone-numbers/${numberId}/`);
return data as { success: boolean; message: string };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['phoneNumbers'] });
queryClient.invalidateQueries({ queryKey: ['communicationUsageStats'] });
},
});
};
/**
* Hook to change a phone number to a different one
*/
export const useChangePhoneNumber = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { numberId: number; new_phone_number: string; friendly_name?: string }) => {
const { numberId, ...body } = params;
const { data } = await apiClient.post(`/communication-credits/phone-numbers/${numberId}/change/`, body);
return data as {
success: boolean;
phone_number: ProxyPhoneNumber;
balance_cents: number;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['phoneNumbers'] });
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
},
});
};

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>

View File

@@ -0,0 +1,145 @@
/**
* Booking Settings Page
*
* Manage booking URLs and customer redirect settings.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
Calendar, Link2, Copy, ExternalLink, Save, CheckCircle
} from 'lucide-react';
import { Business, User } from '../../types';
const BookingSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user, updateBusiness } = useOutletContext<{
business: Business;
user: User;
updateBusiness: (updates: Partial<Business>) => void;
}>();
// Local state
const [showToast, setShowToast] = useState(false);
const [returnUrl, setReturnUrl] = useState(business.bookingReturnUrl || '');
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
const isOwner = user.role === 'owner';
const handleSaveReturnUrl = async () => {
setReturnUrlSaving(true);
try {
await updateBusiness({ bookingReturnUrl: returnUrl });
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
} catch (error) {
alert('Failed to save return URL');
} finally {
setReturnUrlSaving(false);
}
};
if (!isOwner) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Calendar className="text-brand-500" />
{t('settings.booking.title', 'Booking')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure your booking page URL and customer redirect settings.
</p>
</div>
{/* Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Share this URL with your customers so they can book appointments with you.
</p>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
{business.subdomain}.smoothschedule.com
</code>
<button
onClick={() => {
navigator.clipboard.writeText(`https://${business.subdomain}.smoothschedule.com`);
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Copy to clipboard"
>
<Copy size={16} />
</button>
<a
href={`https://${business.subdomain}.smoothschedule.com`}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-brand-500 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title="Open booking page"
>
<ExternalLink size={16} />
</a>
</div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Want to use your own domain? Set up a <a href="/settings/custom-domains" className="text-brand-500 hover:underline">custom domain</a>.
</p>
</section>
{/* Return URL - Where to redirect customers after booking */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<ExternalLink size={20} className="text-green-500" /> Return URL
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
After a customer completes a booking, redirect them to this URL (e.g., a thank you page on your website).
</p>
<div className="flex gap-2">
<input
type="url"
value={returnUrl}
onChange={(e) => setReturnUrl(e.target.value)}
placeholder="https://yourbusiness.com/thank-you"
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500 text-sm"
/>
<button
onClick={handleSaveReturnUrl}
disabled={returnUrlSaving}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium text-sm"
>
<Save size={16} />
{returnUrlSaving ? 'Saving...' : 'Save'}
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Leave empty to keep customers on the booking confirmation page.
</p>
</section>
{/* Toast */}
{showToast && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
<CheckCircle size={18} />
Copied to clipboard
</div>
)}
</div>
);
};
export default BookingSettings;

View File

@@ -2,13 +2,17 @@
* Branding Settings Page
*
* Logo uploads, colors, and display preferences.
* Features live preview of color changes that revert on navigation/reload if not saved.
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
import { Business, User } from '../../types';
import { applyBrandColors } from '../../utils/colorUtils';
import { UpgradePrompt } from '../../components/UpgradePrompt';
import { FeatureKey } from '../../hooks/usePlanFeatures';
// Color palette options
const colorPalettes = [
@@ -26,10 +30,12 @@ const colorPalettes = [
const BrandingSettings: React.FC = () => {
const { t } = useTranslation();
const { business, updateBusiness, user } = useOutletContext<{
const { business, updateBusiness, user, isFeatureLocked, lockedFeature } = useOutletContext<{
business: Business;
updateBusiness: (updates: Partial<Business>) => void;
user: User;
isFeatureLocked?: boolean;
lockedFeature?: FeatureKey;
}>();
const [formState, setFormState] = useState({
@@ -41,8 +47,37 @@ const BrandingSettings: React.FC = () => {
});
const [showToast, setShowToast] = useState(false);
// Store the original saved colors to restore on unmount/navigation
const savedColorsRef = useRef({
primary: business.primaryColor,
secondary: business.secondaryColor || business.primaryColor,
});
// Live preview: Update CSS variables as user cycles through palettes
useEffect(() => {
applyBrandColors(formState.primaryColor, formState.secondaryColor);
// Cleanup: Restore saved colors when component unmounts (navigation away)
return () => {
applyBrandColors(savedColorsRef.current.primary, savedColorsRef.current.secondary);
};
}, [formState.primaryColor, formState.secondaryColor]);
// Update savedColorsRef when business data changes (after successful save)
useEffect(() => {
savedColorsRef.current = {
primary: business.primaryColor,
secondary: business.secondaryColor || business.primaryColor,
};
}, [business.primaryColor, business.secondaryColor]);
const handleSave = async () => {
await updateBusiness(formState);
// Update the saved reference so cleanup doesn't revert
savedColorsRef.current = {
primary: formState.primaryColor,
secondary: formState.secondaryColor,
};
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
@@ -63,6 +98,11 @@ const BrandingSettings: React.FC = () => {
);
}
// Show upgrade prompt if feature is locked
if (isFeatureLocked && lockedFeature) {
return <UpgradePrompt feature={lockedFeature} />;
}
return (
<div className="space-y-6">
{/* Header */}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
/**
* Domains Settings Page
* Custom Domains Settings Page
*
* Manage custom domains and booking URLs for the business.
* Manage custom domains - BYOD and domain purchase.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, Link } from 'react-router-dom';
import {
Globe, Link2, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Crown
Globe, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Lock, ArrowUpRight
} from 'lucide-react';
import { Business, User, CustomDomain } from '../../types';
import {
@@ -21,9 +21,8 @@ import {
} from '../../hooks/useCustomDomains';
import DomainPurchase from '../../components/DomainPurchase';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
const DomainsSettings: React.FC = () => {
const CustomDomainsSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
@@ -115,51 +114,45 @@ const DomainsSettings: React.FC = () => {
);
}
const isCustomDomainLocked = !canUse('custom_domain');
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Globe className="text-indigo-500" />
{t('settings.domains.title', 'Custom Domains')}
{t('settings.customDomains.title', 'Custom Domains')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure custom domains for your booking pages.
Use your own domains for your booking pages.
</p>
</div>
<LockedSection feature="custom_domain" isLocked={!canUse('custom_domain')}>
{/* Quick Domain Setup - Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
{business.subdomain}.smoothschedule.com
</code>
<button
onClick={() => navigator.clipboard.writeText(`${business.subdomain}.smoothschedule.com`)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<Copy size={16} />
</button>
</div>
</section>
{/* Custom Domains Management */}
{business.plan !== 'Free' ? (
<>
{/* Custom Domains Management - with overlay when locked */}
<div className="relative">
{isCustomDomainLocked && (
<div className="absolute inset-0 z-10 bg-white/70 dark:bg-gray-900/70 backdrop-blur-[2px] rounded-xl flex items-center justify-center">
<Link
to="/settings/billing"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white font-semibold rounded-lg shadow-lg hover:from-amber-600 hover:to-orange-600 transition-all"
>
<Lock size={18} />
Upgrade to Enable Custom Domains
<ArrowUpRight size={18} />
</Link>
</div>
)}
<div className={isCustomDomainLocked ? 'pointer-events-none select-none' : ''}>
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Globe size={20} className="text-indigo-500" />
Custom Domains
Bring Your Own Domain
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Use your own domains for your booking pages
Connect a domain you already own
</p>
</div>
</div>
@@ -288,7 +281,7 @@ const DomainsSettings: React.FC = () => {
</section>
{/* Domain Purchase */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
@@ -302,26 +295,8 @@ const DomainsSettings: React.FC = () => {
</div>
<DomainPurchase />
</section>
</>
) : (
/* Upgrade prompt for free plans */
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<Crown size={24} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Unlock Custom Domains</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to use your own domain (e.g., <span className="font-mono">book.yourbusiness.com</span>) or purchase a new one.
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={16} /> View Plans
</button>
</div>
</div>
</section>
)}
</div>
</div>
{/* Toast */}
{showToast && (
@@ -330,9 +305,8 @@ const DomainsSettings: React.FC = () => {
Changes saved successfully
</div>
)}
</LockedSection>
</div>
);
};
export default DomainsSettings;
export default CustomDomainsSettings;

View File

@@ -7,7 +7,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Building2, Save, Check } from 'lucide-react';
import { Building2, Save, Check, Globe } from 'lucide-react';
import { Business, User } from '../../types';
const GeneralSettings: React.FC = () => {
@@ -23,14 +23,40 @@ const GeneralSettings: React.FC = () => {
subdomain: business.subdomain,
contactEmail: business.contactEmail || '',
phone: business.phone || '',
timezone: business.timezone || 'America/New_York',
timezoneDisplayMode: business.timezoneDisplayMode || 'business',
});
const [showToast, setShowToast] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormState(prev => ({ ...prev, [name]: value }));
};
// Common timezones grouped by region
const commonTimezones = [
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
{ value: 'America/Anchorage', label: 'Alaska Time' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Central European Time' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Asia/Tokyo', label: 'Japan Time' },
{ value: 'Asia/Shanghai', label: 'China Time' },
{ value: 'Asia/Singapore', label: 'Singapore Time' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
{ value: 'UTC', label: 'UTC' },
];
const handleSave = async () => {
await updateBusiness(formState);
setShowToast(true);
@@ -103,6 +129,59 @@ const GeneralSettings: React.FC = () => {
</div>
</section>
{/* Timezone Settings */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Globe size={20} className="text-brand-500" />
{t('settings.timezone.title', 'Timezone Settings')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.timezone.businessTimezone', 'Business Timezone')}
</label>
<select
name="timezone"
value={formState.timezone}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
>
{commonTimezones.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.timezone.displayMode', 'Time Display Mode')}
</label>
<select
name="timezoneDisplayMode"
value={formState.timezoneDisplayMode}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
>
<option value="business">
{t('settings.timezone.businessMode', 'Business Timezone')}
</option>
<option value="viewer">
{t('settings.timezone.viewerMode', "Viewer's Local Timezone")}
</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formState.timezoneDisplayMode === 'business'
? t('settings.timezone.businessModeHint', 'All appointment times are displayed in your business timezone.')
: t('settings.timezone.viewerModeHint', 'Appointment times adapt to each viewer\'s local timezone.')}
</p>
</div>
</div>
</section>
{/* Contact Information */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -56,6 +56,8 @@ export interface Business {
logoUrl?: string;
emailLogoUrl?: string;
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
timezone?: string; // IANA timezone (e.g., 'America/New_York')
timezoneDisplayMode?: 'business' | 'viewer'; // How times are displayed to users
whitelabelEnabled: boolean;
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
status?: 'Active' | 'Suspended' | 'Trial';
@@ -68,6 +70,7 @@ export interface Business {
initialSetupComplete?: boolean;
customDomain?: string;
customDomainVerified?: boolean;
bookingReturnUrl?: string; // URL to redirect customers after booking completion
stripeConnectAccountId?: string;
websitePages?: Record<string, { name: string; content: PageComponent[] }>;
customerDashboardContent?: PageComponent[];

View File

@@ -0,0 +1,122 @@
/**
* Color utility functions for generating brand color palettes
*/
/**
* Convert hex color to HSL values
*/
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 0, l: 0 };
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 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
*/
export 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
*/
export 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),
};
}
/**
* Apply a color palette to CSS custom properties
*/
export function applyColorPalette(palette: Record<string, string>): void {
const root = document.documentElement;
Object.entries(palette).forEach(([shade, color]) => {
root.style.setProperty(`--color-brand-${shade}`, color);
});
}
/**
* Apply primary and secondary colors including the secondary color variable
*/
export function applyBrandColors(primaryColor: string, secondaryColor?: string): void {
const palette = generateColorPalette(primaryColor);
applyColorPalette(palette);
// Set the secondary color variable (used for gradients)
const root = document.documentElement;
root.style.setProperty('--color-brand-secondary', secondaryColor || primaryColor);
}
/**
* Default brand color palette (blue)
*/
export const defaultColorPalette: Record<string, string> = {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
};

View File

@@ -11,16 +11,16 @@ export default {
},
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
50: 'var(--color-brand-50, #eff6ff)',
100: 'var(--color-brand-100, #dbeafe)',
200: 'var(--color-brand-200, #bfdbfe)',
300: 'var(--color-brand-300, #93c5fd)',
400: 'var(--color-brand-400, #60a5fa)',
500: 'var(--color-brand-500, #3b82f6)',
600: 'var(--color-brand-600, #2563eb)',
700: 'var(--color-brand-700, #1d4ed8)',
800: 'var(--color-brand-800, #1e40af)',
900: 'var(--color-brand-900, #1e3a8a)',
},
},
},