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,12 +111,21 @@ 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">
{!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 && (
<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">
@@ -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">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Add Credits
{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,9 +77,13 @@ 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'
: locked
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
: isActive
? 'bg-white/10 text-white'
: locked
? 'text-white/40 hover:text-white/60 hover:bg-white/5'
: 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = variant === 'settings'
@@ -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'
: locked
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
}`}
>
<Icon size={16} className="shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium">{label}</span>
{locked && (
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
)}
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
{description}

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',
applyColorPalette(defaultColorPalette);
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
};
Object.entries(defaultColors).forEach(([shade, color]) => {
root.style.setProperty(`--color-brand-${shade}`, color);
});
};
}, [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"
{/* 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"
>
<Copy size={16} />
</button>
<Lock size={18} />
Upgrade to Enable Custom Domains
<ArrowUpRight size={18} />
</Link>
</div>
</section>
{/* Custom Domains Management */}
{business.plan !== 'Free' ? (
<>
)}
<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>
)}
{/* 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)',
},
},
},

View File

@@ -13,14 +13,15 @@ REDIS_URL=redis://redis:6379/0
CELERY_FLOWER_USER=aHPdcOatgRsYSHJThjUFyLTrzRXkiVsp
CELERY_FLOWER_PASSWORD=mH26NSH3PjskvgwrXplFvX1zFyIjl7O3Tqr9ddpbxd6zjceofepCcITJFVjS9ZwH
# Twilio (for SMS 2FA)
# Twilio (for SMS 2FA and phone numbers)
# ------------------------------------------------------------------------------
TWILIO_ACCOUNT_SID=AC10d7f7a218404da2219310918ec6f41b
TWILIO_AUTH_TOKEN=d6223df9fcd9ebd13cc64a3e20e01b3c
# Live credentials for phone number search/purchase
TWILIO_ACCOUNT_SID=ACb1f406fb0e8fb4f5a4fc3039c380274d
TWILIO_AUTH_TOKEN=aa0197048407b1522b8588c181818cbf
TWILIO_PHONE_NUMBER=
# Stripe (for payments)
# ------------------------------------------------------------------------------
STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA
STRIPE_SECRET_KEY=sk_test_51SYttT4pb5kWPtNtQUCOMFGHlkMRc88TYAuliEQdZsAb4Rs3mq1OJ4iS1ydQpSPYO3tmnZfm1y1tuMABq7188jsV00VVkfdD6q
STRIPE_WEBHOOK_SECRET=whsec_RH4ab9rFuBNdjw8LJ9IimHf1uVukCJIi
STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
STRIPE_SECRET_KEY=sk_test_51Sa2i4G4IkZ6cJFIQb8tlKZdnSJzBrAzT4iwla9IrIGvOp0ozlLTxwLaaxvbKxoV7raHqrH7qw9UTeF1BZf4yVWT000IQWACgj
STRIPE_WEBHOOK_SECRET=whsec_placeholder

View File

@@ -329,9 +329,12 @@ SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSocialSignupFor
# -------------------------------------------------------------------------------
# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
# TokenAuthentication must come first so API requests using Token header
# are authenticated without CSRF. SessionAuthentication enforces CSRF
# when it processes requests with session cookies.
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-12-03 05:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_add_stripe_customer_id'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='timezone',
field=models.CharField(default='America/New_York', help_text="Business timezone (IANA format, e.g., 'America/New_York')", max_length=50),
),
migrations.AddField(
model_name='tenant',
name='timezone_display_mode',
field=models.CharField(choices=[('business', 'Business Timezone'), ('viewer', 'Viewer Timezone')], default='business', help_text="How appointment times are displayed: 'business' shows times in the business timezone, 'viewer' shows times in each viewer's local timezone", max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-03 06:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_add_timezone_fields'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='booking_return_url',
field=models.URLField(blank=True, default='', help_text='URL to redirect customers after they complete a booking (e.g., https://yourbusiness.com/thank-you)', max_length=500),
),
]

View File

@@ -79,6 +79,30 @@ class Tenant(TenantMixin):
contact_email = models.EmailField(blank=True)
phone = models.CharField(max_length=20, blank=True)
# Timezone Settings
timezone = models.CharField(
max_length=50,
default='America/New_York',
help_text="Business timezone (IANA format, e.g., 'America/New_York')"
)
timezone_display_mode = models.CharField(
max_length=20,
choices=[
('business', 'Business Timezone'),
('viewer', 'Viewer Timezone'),
],
default='business',
help_text="How appointment times are displayed: 'business' shows times in the business timezone, 'viewer' shows times in each viewer's local timezone"
)
# Booking Settings
booking_return_url = models.URLField(
max_length=500,
blank=True,
default='',
help_text="URL to redirect customers after they complete a booking (e.g., https://yourbusiness.com/thank-you)"
)
# OAuth Settings - which providers are enabled for this business
oauth_enabled_providers = models.JSONField(
default=list,

View File

@@ -198,6 +198,11 @@ def current_business_view(request):
'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,
'logo_display_mode': tenant.logo_display_mode,
# Timezone settings
'timezone': tenant.timezone,
'timezone_display_mode': tenant.timezone_display_mode,
# Booking settings
'booking_return_url': tenant.booking_return_url or '',
# Other optional fields with defaults
'whitelabel_enabled': False,
'resources_can_reschedule': False,
@@ -234,8 +239,9 @@ def update_business_view(request):
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
# Only owners can update business settings
if user.role.lower() != 'tenant_owner':
return Response({'error': 'Only business owners can update settings'}, status=status.HTTP_403_FORBIDDEN)
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
if user.role.upper() not in allowed_roles:
return Response({'error': f'Only business owners can update settings. Your role: {user.role}'}, status=status.HTTP_403_FORBIDDEN)
# Update fields if provided in request
if 'name' in request.data:
@@ -250,6 +256,15 @@ def update_business_view(request):
if 'logo_display_mode' in request.data:
tenant.logo_display_mode = request.data['logo_display_mode']
if 'timezone' in request.data:
tenant.timezone = request.data['timezone']
if 'timezone_display_mode' in request.data:
tenant.timezone_display_mode = request.data['timezone_display_mode']
if 'booking_return_url' in request.data:
tenant.booking_return_url = request.data['booking_return_url'] or ''
# Handle logo uploads (base64 data URLs)
if 'logo_url' in request.data:
logo_data = request.data['logo_url']
@@ -310,6 +325,8 @@ def update_business_view(request):
'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,
'logo_display_mode': tenant.logo_display_mode,
'timezone': tenant.timezone,
'timezone_display_mode': tenant.timezone_display_mode,
'whitelabel_enabled': False,
'resources_can_reschedule': False,
'require_payment_method_to_book': False,

View File

@@ -13,6 +13,12 @@ from .views import (
save_payment_method_view,
get_transactions_view,
get_usage_stats_view,
# Phone number management
search_available_numbers_view,
purchase_phone_number_view,
list_phone_numbers_view,
release_phone_number_view,
change_phone_number_view,
)
app_name = 'comms_credits'
@@ -40,4 +46,11 @@ urlpatterns = [
# Usage stats
path('usage-stats/', get_usage_stats_view, name='usage_stats'),
# Phone number management
path('phone-numbers/', list_phone_numbers_view, name='list_phone_numbers'),
path('phone-numbers/search/', search_available_numbers_view, name='search_available_numbers'),
path('phone-numbers/purchase/', purchase_phone_number_view, name='purchase_phone_number'),
path('phone-numbers/<int:number_id>/', release_phone_number_view, name='release_phone_number'),
path('phone-numbers/<int:number_id>/change/', change_phone_number_view, name='change_phone_number'),
]

View File

@@ -2,18 +2,21 @@
Communication Credits API Views
API endpoints for managing prepaid communication credits.
Integrates with Stripe for payments.
Integrates with Stripe for payments and Twilio for phone numbers.
"""
import stripe
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from twilio.rest import Client as TwilioClient
from twilio.base.exceptions import TwilioRestException, TwilioException
from .models import CommunicationCredits, CreditTransaction
from .models import CommunicationCredits, CreditTransaction, ProxyPhoneNumber
# Initialize Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
@@ -573,3 +576,447 @@ def _get_or_create_stripe_customer(credits, tenant, user):
credits.save(update_fields=['stripe_customer_id', 'updated_at'])
return customer.id
def _get_twilio_client():
"""Get a Twilio client using settings."""
account_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', None)
auth_token = getattr(settings, 'TWILIO_AUTH_TOKEN', None)
if not account_sid or not auth_token:
raise ValueError("Twilio credentials not configured")
return TwilioClient(account_sid, auth_token)
# =============================================================================
# Phone Number Management Endpoints
# =============================================================================
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def search_available_numbers_view(request):
"""
Search for available phone numbers to purchase from Twilio.
Query params:
- area_code: Optional 3-digit area code to search within
- contains: Optional digits the number should contain
- country: Country code (default: US)
- limit: Number of results (default: 20, max: 50)
"""
tenant = request.tenant
if not tenant:
return Response(
{'error': 'No business context'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if tenant has masked calling feature
if not tenant.has_feature('can_use_masked_phone_numbers'):
return Response(
{'error': 'Masked calling feature not available on your plan'},
status=status.HTTP_403_FORBIDDEN
)
area_code = request.query_params.get('area_code', '')
contains = request.query_params.get('contains', '')
country = request.query_params.get('country', 'US')
limit = min(int(request.query_params.get('limit', 20)), 50)
try:
client = _get_twilio_client()
# Build search params
search_params = {
'voice_enabled': True,
'sms_enabled': True,
'limit': limit,
}
if area_code:
search_params['area_code'] = area_code
if contains:
search_params['contains'] = contains
# Search for available numbers
available_numbers = client.available_phone_numbers(country).local.list(**search_params)
results = []
for number in available_numbers:
results.append({
'phone_number': number.phone_number,
'friendly_name': number.friendly_name,
'locality': number.locality,
'region': number.region,
'postal_code': number.postal_code,
'capabilities': {
'voice': number.capabilities.get('voice', False),
'sms': number.capabilities.get('SMS', False),
'mms': number.capabilities.get('MMS', False),
},
'monthly_cost_cents': 200, # $2.00/month flat rate
})
return Response({
'numbers': results,
'count': len(results),
})
except (TwilioRestException, TwilioException) as e:
error_msg = str(e)
# Check for test credentials error
if '20008' in error_msg or 'Test Account' in error_msg:
return Response(
{'error': 'Phone number search requires live Twilio credentials. Please configure your Twilio account.'},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response(
{'error': f'Failed to search phone numbers: {error_msg}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def purchase_phone_number_view(request):
"""
Purchase a phone number from Twilio and assign to tenant.
Expects:
- phone_number: The E.164 phone number to purchase
- friendly_name: Optional friendly name for the number
Charges $2.00 from credits for the purchase fee.
"""
tenant = request.tenant
if not tenant:
return Response(
{'error': 'No business context'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if tenant has masked calling feature
if not tenant.has_feature('can_use_masked_phone_numbers'):
return Response(
{'error': 'Masked calling feature not available on your plan'},
status=status.HTTP_403_FORBIDDEN
)
phone_number = request.data.get('phone_number')
friendly_name = request.data.get('friendly_name', '')
if not phone_number:
return Response(
{'error': 'Phone number is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if already purchased
existing = ProxyPhoneNumber.objects.filter(phone_number=phone_number).first()
if existing:
if existing.assigned_tenant == tenant:
return Response(
{'error': 'You already own this number'},
status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
{'error': 'This number is not available'},
status=status.HTTP_400_BAD_REQUEST
)
credits = get_or_create_credits(tenant)
# Check if tenant has enough credits for purchase fee ($2.00)
purchase_fee_cents = 200
if credits.balance_cents < purchase_fee_cents:
return Response(
{'error': f'Insufficient credits. Purchase fee is $2.00, you have ${credits.balance_cents/100:.2f}'},
status=status.HTTP_400_BAD_REQUEST
)
try:
client = _get_twilio_client()
with transaction.atomic():
# Purchase the number from Twilio
purchased = client.incoming_phone_numbers.create(
phone_number=phone_number,
friendly_name=friendly_name or f"SmoothSchedule - {tenant.name}",
voice_url=settings.TWILIO_VOICE_WEBHOOK_URL if hasattr(settings, 'TWILIO_VOICE_WEBHOOK_URL') else None,
sms_url=settings.TWILIO_SMS_WEBHOOK_URL if hasattr(settings, 'TWILIO_SMS_WEBHOOK_URL') else None,
)
# Create the proxy number record
proxy_number = ProxyPhoneNumber.objects.create(
phone_number=phone_number,
twilio_sid=purchased.sid,
status=ProxyPhoneNumber.Status.ASSIGNED,
assigned_tenant=tenant,
assigned_at=timezone.now(),
friendly_name=friendly_name,
capabilities={
'voice': purchased.capabilities.get('voice', False),
'sms': purchased.capabilities.get('sms', False),
'mms': purchased.capabilities.get('mms', False),
},
)
# Charge the purchase fee
credits.deduct(
amount_cents=purchase_fee_cents,
description=f"Phone number purchase: {phone_number}",
reference_type='phone_purchase',
reference_id=purchased.sid,
)
return Response({
'success': True,
'phone_number': {
'id': proxy_number.id,
'phone_number': proxy_number.phone_number,
'friendly_name': proxy_number.friendly_name,
'status': proxy_number.status,
'monthly_fee_cents': proxy_number.monthly_fee_cents,
'assigned_at': proxy_number.assigned_at,
},
'balance_cents': credits.balance_cents,
})
except (TwilioRestException, TwilioException) as e:
return Response(
{'error': f'Failed to purchase number: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_phone_numbers_view(request):
"""
List phone numbers assigned to the tenant.
"""
tenant = request.tenant
if not tenant:
return Response(
{'error': 'No business context'},
status=status.HTTP_400_BAD_REQUEST
)
numbers = ProxyPhoneNumber.objects.filter(
assigned_tenant=tenant,
is_active=True,
).order_by('-assigned_at')
results = []
for num in numbers:
results.append({
'id': num.id,
'phone_number': num.phone_number,
'friendly_name': num.friendly_name,
'status': num.status,
'monthly_fee_cents': num.monthly_fee_cents,
'capabilities': num.capabilities,
'assigned_at': num.assigned_at,
'last_billed_at': num.last_billed_at,
})
return Response({
'numbers': results,
'count': len(results),
})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def release_phone_number_view(request, number_id):
"""
Release a phone number back to Twilio.
This will delete the number from your Twilio account.
No charge is applied for releasing.
"""
tenant = request.tenant
if not tenant:
return Response(
{'error': 'No business context'},
status=status.HTTP_400_BAD_REQUEST
)
try:
proxy_number = ProxyPhoneNumber.objects.get(
id=number_id,
assigned_tenant=tenant,
)
except ProxyPhoneNumber.DoesNotExist:
return Response(
{'error': 'Phone number not found'},
status=status.HTTP_404_NOT_FOUND
)
try:
client = _get_twilio_client()
with transaction.atomic():
# Delete from Twilio
if proxy_number.twilio_sid:
try:
client.incoming_phone_numbers(proxy_number.twilio_sid).delete()
except (TwilioRestException, TwilioException) as e:
# If the number doesn't exist in Twilio anymore, continue
if hasattr(e, 'code') and e.code != 20404: # Not found
raise
# Mark as inactive in our system
proxy_number.status = ProxyPhoneNumber.Status.INACTIVE
proxy_number.is_active = False
proxy_number.assigned_tenant = None
proxy_number.save(update_fields=['status', 'is_active', 'assigned_tenant', 'updated_at'])
return Response({
'success': True,
'message': f'Phone number {proxy_number.phone_number} has been released',
})
except (TwilioRestException, TwilioException) as e:
return Response(
{'error': f'Failed to release number: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def change_phone_number_view(request, number_id):
"""
Change a phone number to a different one.
Expects:
- new_phone_number: The new E.164 phone number to purchase
- friendly_name: Optional friendly name for the new number
Charges $2.00 from credits for the change fee.
Releases the old number and purchases the new one.
"""
tenant = request.tenant
if not tenant:
return Response(
{'error': 'No business context'},
status=status.HTTP_400_BAD_REQUEST
)
try:
old_number = ProxyPhoneNumber.objects.get(
id=number_id,
assigned_tenant=tenant,
)
except ProxyPhoneNumber.DoesNotExist:
return Response(
{'error': 'Phone number not found'},
status=status.HTTP_404_NOT_FOUND
)
new_phone_number = request.data.get('new_phone_number')
friendly_name = request.data.get('friendly_name', old_number.friendly_name)
if not new_phone_number:
return Response(
{'error': 'New phone number is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if new number is available
existing = ProxyPhoneNumber.objects.filter(phone_number=new_phone_number).first()
if existing:
return Response(
{'error': 'This number is not available'},
status=status.HTTP_400_BAD_REQUEST
)
credits = get_or_create_credits(tenant)
# Check if tenant has enough credits for change fee ($2.00)
change_fee_cents = 200
if credits.balance_cents < change_fee_cents:
return Response(
{'error': f'Insufficient credits. Change fee is $2.00, you have ${credits.balance_cents/100:.2f}'},
status=status.HTTP_400_BAD_REQUEST
)
try:
client = _get_twilio_client()
with transaction.atomic():
# Purchase the new number
purchased = client.incoming_phone_numbers.create(
phone_number=new_phone_number,
friendly_name=friendly_name or f"SmoothSchedule - {tenant.name}",
voice_url=settings.TWILIO_VOICE_WEBHOOK_URL if hasattr(settings, 'TWILIO_VOICE_WEBHOOK_URL') else None,
sms_url=settings.TWILIO_SMS_WEBHOOK_URL if hasattr(settings, 'TWILIO_SMS_WEBHOOK_URL') else None,
)
# Release the old number from Twilio
if old_number.twilio_sid:
try:
client.incoming_phone_numbers(old_number.twilio_sid).delete()
except (TwilioRestException, TwilioException):
pass # If deletion fails, continue anyway
# Update the proxy number record with new number
old_number.phone_number = new_phone_number
old_number.twilio_sid = purchased.sid
old_number.friendly_name = friendly_name
old_number.capabilities = {
'voice': purchased.capabilities.get('voice', False),
'sms': purchased.capabilities.get('sms', False),
'mms': purchased.capabilities.get('mms', False),
}
old_number.save(update_fields=[
'phone_number', 'twilio_sid', 'friendly_name', 'capabilities', 'updated_at'
])
# Charge the change fee
credits.deduct(
amount_cents=change_fee_cents,
description=f"Phone number change to {new_phone_number}",
reference_type='phone_change',
reference_id=purchased.sid,
)
return Response({
'success': True,
'phone_number': {
'id': old_number.id,
'phone_number': old_number.phone_number,
'friendly_name': old_number.friendly_name,
'status': old_number.status,
'monthly_fee_cents': old_number.monthly_fee_cents,
'assigned_at': old_number.assigned_at,
},
'balance_cents': credits.balance_cents,
})
except (TwilioRestException, TwilioException) as e:
return Response(
{'error': f'Failed to change number: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)