-
{label}
+
+ {label}
+ {locked && (
+
+ )}
+
{description && (
{description}
diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts
index d1c3ef2..4987f0e 100644
--- a/frontend/src/hooks/useBusiness.ts
+++ b/frontend/src/hooks/useBusiness.ts
@@ -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;
}
diff --git a/frontend/src/hooks/useCommunicationCredits.ts b/frontend/src/hooks/useCommunicationCredits.ts
index a118fca..ceb9bfe 100644
--- a/frontend/src/hooks/useCommunicationCredits.ts
+++ b/frontend/src/hooks/useCommunicationCredits.ts
@@ -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'] });
+ },
+ });
+};
diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx
index 107a5b7..c762002 100644
--- a/frontend/src/layouts/BusinessLayout.tsx
+++ b/frontend/src/layouts/BusinessLayout.tsx
@@ -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 {
- 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 = ({ 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 = {
- 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(() => {
diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx
index 4593a18..34ad844 100644
--- a/frontend/src/layouts/SettingsLayout.tsx
+++ b/frontend/src/layouts/SettingsLayout.tsx
@@ -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) => void;
}
+// Map settings pages to their required plan features
+const SETTINGS_PAGE_FEATURES: Record = {
+ '/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();
+ // 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 (
{/* Settings Sidebar */}
@@ -74,33 +95,52 @@ const SettingsLayout: React.FC = () => {
label={t('settings.general.title', 'General')}
description={t('settings.general.description', 'Name, timezone, contact')}
/>
-
+
+
+
+ {/* Branding Section */}
+
+
+
+
{/* Integrations Section */}
-
@@ -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')}
/>
@@ -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')}
/>
@@ -151,7 +193,11 @@ const SettingsLayout: React.FC = () => {
{/* Content Area */}
-
+
diff --git a/frontend/src/pages/settings/BookingSettings.tsx b/frontend/src/pages/settings/BookingSettings.tsx
new file mode 100644
index 0000000..6fac2a2
--- /dev/null
+++ b/frontend/src/pages/settings/BookingSettings.tsx
@@ -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) => 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 (
+
+
+ Only the business owner can access these settings.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('settings.booking.title', 'Booking')}
+
+
+ Configure your booking page URL and customer redirect settings.
+
+
+
+ {/* Booking URL */}
+
+
+ Your Booking URL
+
+
+ Share this URL with your customers so they can book appointments with you.
+
+
+
+ {business.subdomain}.smoothschedule.com
+
+
+
+
+
+
+
+ Want to use your own domain? Set up a custom domain.
+
+
+
+ {/* Return URL - Where to redirect customers after booking */}
+
+
+ Return URL
+
+
+ After a customer completes a booking, redirect them to this URL (e.g., a thank you page on your website).
+
+
+ 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"
+ />
+
+
+
+ Leave empty to keep customers on the booking confirmation page.
+
+
+
+ {/* Toast */}
+ {showToast && (
+
+
+ Copied to clipboard
+
+ )}
+
+ );
+};
+
+export default BookingSettings;
diff --git a/frontend/src/pages/settings/BrandingSettings.tsx b/frontend/src/pages/settings/BrandingSettings.tsx
index d51f5cb..fa29110 100644
--- a/frontend/src/pages/settings/BrandingSettings.tsx
+++ b/frontend/src/pages/settings/BrandingSettings.tsx
@@ -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) => 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 ;
+ }
+
return (
{/* Header */}
diff --git a/frontend/src/pages/settings/CommunicationSettings.tsx b/frontend/src/pages/settings/CommunicationSettings.tsx
index b9363c3..a9a262d 100644
--- a/frontend/src/pages/settings/CommunicationSettings.tsx
+++ b/frontend/src/pages/settings/CommunicationSettings.tsx
@@ -9,13 +9,21 @@ import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
Phone, Wallet, RefreshCw, Check, CreditCard, Loader2,
- ArrowUpRight, ArrowDownRight, Clock, Save, MessageSquare
+ ArrowUpRight, ArrowDownRight, ArrowRight, Clock, Save, MessageSquare,
+ PhoneCall, Trash2, RefreshCcw, Search, Plus, AlertCircle, X, CheckCircle
} from 'lucide-react';
import { Business, User } from '../../types';
import {
useCommunicationCredits,
useCreditTransactions,
useUpdateCreditsSettings,
+ usePhoneNumbers,
+ useSearchPhoneNumbers,
+ usePurchasePhoneNumber,
+ useReleasePhoneNumber,
+ useChangePhoneNumber,
+ ProxyPhoneNumber,
+ AvailablePhoneNumber,
} from '../../hooks/useCommunicationCredits';
import { CreditPaymentModal } from '../../components/CreditPaymentForm';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
@@ -32,6 +40,13 @@ const CommunicationSettings: React.FC = () => {
const { data: transactions } = useCreditTransactions(1, 10);
const updateSettings = useUpdateCreditsSettings();
+ // Phone number hooks
+ const { data: phoneNumbers, isLoading: phoneNumbersLoading } = usePhoneNumbers();
+ const searchPhoneNumbers = useSearchPhoneNumbers();
+ const purchasePhoneNumber = usePurchasePhoneNumber();
+ const releasePhoneNumber = useReleasePhoneNumber();
+ const changePhoneNumber = useChangePhoneNumber();
+
// Wizard state
const [showWizard, setShowWizard] = useState(false);
const [wizardStep, setWizardStep] = useState(1);
@@ -42,7 +57,6 @@ const CommunicationSettings: React.FC = () => {
maskedCallingEnabled: false,
avgCallMinutes: 3,
callsPerMonth: 20,
- dedicatedNumberNeeded: false,
callingPattern: 'sequential' as 'concurrent' | 'sequential',
staffCount: 1,
maxDailyAppointmentsPerStaff: 8,
@@ -59,6 +73,22 @@ const CommunicationSettings: React.FC = () => {
// Top-up modal state
const [showTopUp, setShowTopUp] = useState(false);
const [topUpAmount, setTopUpAmount] = useState(2500);
+ const [skipAmountSelection, setSkipAmountSelection] = useState(false);
+
+ // Phone number management state
+ const [showPhoneSearch, setShowPhoneSearch] = useState(false);
+ const [phoneSearchQuery, setPhoneSearchQuery] = useState({ area_code: '', contains: '' });
+ const [availableNumbers, setAvailableNumbers] = useState
([]);
+ const [selectedNumber, setSelectedNumber] = useState(null);
+ const [numberToRelease, setNumberToRelease] = useState(null);
+ const [numberToChange, setNumberToChange] = useState(null);
+ const [phoneError, setPhoneError] = useState(null);
+
+ // Wizard phone number selection state (single number only)
+ const [showWizardPhoneModal, setShowWizardPhoneModal] = useState(false);
+ const [wizardSelectedNumber, setWizardSelectedNumber] = useState(null);
+ const [wizardPhoneSearchQuery, setWizardPhoneSearchQuery] = useState({ area_code: '', contains: '' });
+ const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState([]);
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
@@ -78,16 +108,9 @@ const CommunicationSettings: React.FC = () => {
// Check if needs setup
const needsSetup = !credits || (credits.balance_cents === 0 && credits.total_loaded_cents === 0);
- // Calculate recommended phone numbers based on calling pattern
- const getRecommendedPhoneNumbers = () => {
- if (!wizardData.maskedCallingEnabled || !wizardData.dedicatedNumberNeeded) {
- return 0;
- }
- if (wizardData.callingPattern === 'sequential') {
- return Math.max(1, Math.ceil(wizardData.staffCount / 3));
- } else {
- return wizardData.maxDailyAppointmentsPerStaff;
- }
+ // Check if phone number is needed (single number for all SMS and calling)
+ const needsPhoneNumber = () => {
+ return wizardData.smsRemindersEnabled || wizardData.maskedCallingEnabled;
};
// Calculate estimated monthly cost
@@ -101,9 +124,9 @@ const CommunicationSettings: React.FC = () => {
const callMinutes = wizardData.callsPerMonth * wizardData.avgCallMinutes;
totalCents += callMinutes * 5;
}
- if (wizardData.dedicatedNumberNeeded) {
- const recommendedNumbers = getRecommendedPhoneNumbers();
- totalCents += recommendedNumbers * 200;
+ // Single phone number for both SMS and calling ($2/month)
+ if (needsPhoneNumber()) {
+ totalCents += 200;
}
return totalCents;
};
@@ -140,6 +163,92 @@ const CommunicationSettings: React.FC = () => {
});
};
+ const formatPhoneNumber = (phone: string) => {
+ const cleaned = phone.replace(/\D/g, '');
+ if (cleaned.length === 11 && cleaned.startsWith('1')) {
+ return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
+ }
+ return phone;
+ };
+
+ const handleSearchPhoneNumbers = async () => {
+ setPhoneError(null);
+ try {
+ const result = await searchPhoneNumbers.mutateAsync(phoneSearchQuery);
+ setAvailableNumbers(result.numbers);
+ } catch (err: any) {
+ setPhoneError(err.response?.data?.error || 'Failed to search phone numbers');
+ }
+ };
+
+ const handlePurchaseNumber = async (number: AvailablePhoneNumber) => {
+ setPhoneError(null);
+ try {
+ await purchasePhoneNumber.mutateAsync({
+ phone_number: number.phone_number,
+ friendly_name: number.friendly_name,
+ });
+ setShowPhoneSearch(false);
+ setAvailableNumbers([]);
+ setSelectedNumber(null);
+ } catch (err: any) {
+ setPhoneError(err.response?.data?.error || 'Failed to purchase phone number');
+ }
+ };
+
+ const handleReleaseNumber = async () => {
+ if (!numberToRelease) return;
+ setPhoneError(null);
+ try {
+ await releasePhoneNumber.mutateAsync(numberToRelease.id);
+ setNumberToRelease(null);
+ } catch (err: any) {
+ setPhoneError(err.response?.data?.error || 'Failed to release phone number');
+ }
+ };
+
+ const handleChangeNumber = async (newNumber: AvailablePhoneNumber) => {
+ if (!numberToChange) return;
+ setPhoneError(null);
+ try {
+ await changePhoneNumber.mutateAsync({
+ numberId: numberToChange.id,
+ new_phone_number: newNumber.phone_number,
+ friendly_name: newNumber.friendly_name,
+ });
+ setNumberToChange(null);
+ setAvailableNumbers([]);
+ } catch (err: any) {
+ setPhoneError(err.response?.data?.error || 'Failed to change phone number');
+ }
+ };
+
+ // Wizard phone number selection handlers
+ const handleWizardPhoneSearch = async () => {
+ setPhoneError(null);
+ try {
+ const result = await searchPhoneNumbers.mutateAsync(wizardPhoneSearchQuery);
+ setWizardAvailableNumbers(result.numbers);
+ } catch (err: any) {
+ setPhoneError(err.response?.data?.error || 'Failed to search phone numbers');
+ }
+ };
+
+ const handleWizardSelectNumber = (number: AvailablePhoneNumber) => {
+ // Single number selection - toggle on/off
+ if (wizardSelectedNumber?.phone_number === number.phone_number) {
+ setWizardSelectedNumber(null);
+ } else {
+ setWizardSelectedNumber(number);
+ }
+ };
+
+ const handleWizardPhoneConfirm = () => {
+ // Close modal and advance to step 4
+ setShowWizardPhoneModal(false);
+ setWizardStep(4);
+ };
+
if (!isOwner) {
return (
@@ -374,9 +483,133 @@ const CommunicationSettings: React.FC = () => {
Cost: $0.05 per minute of voice calling
+
+ {/* Phone Number Configuration for Masked Calling */}
+
+
+
+ Phone Numbers for Masked Calling
+
+
+ You'll need dedicated phone numbers for masked calling ($2/month each)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Phone Number Info */}
+
+
+
+
+ Phone Number Required
+
+
+
+ You'll need 1 phone number for all outbound calls.
+ {wizardData.smsRemindersEnabled && ' This number will also handle your SMS messages.'}
+
+
+ Monthly cost: {formatCurrency(200)}
+
+
+
+
>
)}
+ {/* Phone Number Notice for SMS-only (when calling is not enabled) */}
+ {wizardData.smsRemindersEnabled && !wizardData.maskedCallingEnabled && (
+
+
+
+
+ Phone Number Required for SMS
+
+
+
+ You'll need 1 phone number to send and receive SMS messages. Cost: $2/month.
+
+
+ You can purchase a phone number after completing setup.
+
+
+ )}
+
-
+ {needsPhoneNumber() ? (
+
+ ) : (
+
+ )}
)}
- {/* Step 4: Summary and Load Credits */}
+ {/* Step 4: Auto-Reload Setup */}
{wizardStep === 4 && (
-
-
- Estimated Monthly Costs
-
-
-
+ {/* Monthly Estimate Summary */}
+
+
+
+ Your estimated monthly usage
+
+
+ {formatCurrency(calculateEstimate())}/month
+
+
+
{wizardData.smsRemindersEnabled && (
-
-
- SMS Messages ({wizardData.appointmentsPerMonth * wizardData.smsPerAppointment}/mo)
-
-
- {formatCurrency(wizardData.appointmentsPerMonth * wizardData.smsPerAppointment * 3)}
-
-
+
SMS: {wizardData.appointmentsPerMonth * wizardData.smsPerAppointment} messages
)}
-
+ {wizardData.smsRemindersEnabled && wizardData.maskedCallingEnabled &&
ยท }
{wizardData.maskedCallingEnabled && (
-
-
- Voice Calling ({wizardData.callsPerMonth * wizardData.avgCallMinutes} min/mo)
-
-
- {formatCurrency(wizardData.callsPerMonth * wizardData.avgCallMinutes * 5)}
-
-
+
Calls: {wizardData.callsPerMonth * wizardData.avgCallMinutes} minutes
)}
+
+
-
-
-
Total Estimated
-
- {formatCurrency(calculateEstimate())}/month
-
+ {/* Auto-Reload Configuration */}
+
+
+ Set Up Auto-Reload
+
+
+ We'll automatically keep your balance topped up so your services never get interrupted.
+
+
+ {/* Reload Amount Selection */}
+
+
+
+ {[1000, 2500, 5000, 10000].map((amount) => (
+
+ ))}
+
+ $
+ {
+ const val = Math.max(5, parseInt(e.target.value) || 5) * 100;
+ setTopUpAmount(val);
+ setSettingsForm({
+ ...settingsForm,
+ auto_reload_enabled: true,
+ auto_reload_amount_cents: val,
+ });
+ }}
+ className={`w-full py-3 pl-7 pr-3 rounded-lg border-2 transition-colors text-center font-semibold ${
+ ![1000, 2500, 5000, 10000].includes(topUpAmount)
+ ? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
+ }`}
+ />
+
+ {/* Low Threshold Selection */}
+
+
+
+ {[500, 1000, 2000, 5000].map((threshold) => (
+
+ ))}
+
+ $
+ {
+ const val = Math.max(1, parseInt(e.target.value) || 1) * 100;
+ setSettingsForm({
+ ...settingsForm,
+ auto_reload_threshold_cents: val,
+ low_balance_warning_cents: Math.round(val * 0.5),
+ });
+ }}
+ className={`w-full py-3 pl-7 pr-3 rounded-lg border-2 transition-colors text-center font-semibold ${
+ ![500, 1000, 2000, 5000].includes(settingsForm.auto_reload_threshold_cents)
+ ? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
+ }`}
+ />
+
+
+
+
+ {/* Summary */}
+
+
+ Summary
+
+
+
+ Today: We'll charge {formatCurrency(topUpAmount)} to load your initial credits.
+
+
+ Auto-reload: When your balance drops below {formatCurrency(settingsForm.auto_reload_threshold_cents)}, we'll automatically charge {formatCurrency(topUpAmount)} again.
+
+
+
-
-
- Recommended Starting Balance
-
-
- {formatCurrency(getRecommendedBalance())}
-
-
- This covers approximately 2-3 months of estimated usage with a safety buffer
-
-
-
-
-
-
- {[1000, 2500, 5000, 10000].map((amount) => (
-
- ))}
-
-
-
+ {/* Action Buttons */}
)}
@@ -540,6 +865,97 @@ const CommunicationSettings: React.FC = () => {
+ {/* Phone Numbers Section - needed for both SMS and calling */}
+ {(canUse('sms_reminders') || canUse('masked_calling')) && (
+
+
+
+
+ Your Phone Number
+
+ {/* Only one phone number allowed - no add button once they have one */}
+
+
+ {phoneNumbersLoading ? (
+
+
+
+ ) : phoneNumbers?.numbers && phoneNumbers.numbers.length > 0 ? (
+
+ {phoneNumbers.numbers.map((num) => (
+
+
+
+
+
+ {formatPhoneNumber(num.phone_number)}
+
+ {num.friendly_name && (
+
+ {num.friendly_name}
+
+ )}
+
+ {num.capabilities.voice && (
+
+ Voice
+
+ )}
+ {num.capabilities.sms && (
+
+ SMS
+
+ )}
+
+
+
+
+
+ {formatCurrency(num.monthly_fee_cents)}/mo
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No phone numbers yet
+
+
+
+ )}
+
+ )}
+
{/* Auto-Reload Settings */}
@@ -721,10 +1137,412 @@ const CommunicationSettings: React.FC = () => {
{/* Credit Payment Modal */}
setShowTopUp(false)}
- defaultAmount={topUpAmount}
+ onClose={() => {
+ setShowTopUp(false);
+ setSkipAmountSelection(false);
+ }}
+ amountCents={topUpAmount}
+ onAmountChange={setTopUpAmount}
onSuccess={handlePaymentSuccess}
+ skipAmountSelection={skipAmountSelection}
/>
+
+ {/* Phone Number Search Modal */}
+ {showPhoneSearch && (
+
+
+
+
+
+ {numberToChange ? 'Change Phone Number' : 'Add Phone Number'}
+
+
+ {numberToChange
+ ? `Select a new number to replace ${formatPhoneNumber(numberToChange.phone_number)} ($2 fee)`
+ : 'Search for available phone numbers ($2 purchase fee)'}
+
+
+
+
+
+
+ {/* Search Form */}
+
+
+
+
+ setPhoneSearchQuery({ ...phoneSearchQuery, area_code: e.target.value.replace(/\D/g, '') })
+ }
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+
+
+
+
+ setPhoneSearchQuery({ ...phoneSearchQuery, contains: e.target.value.replace(/\D/g, '') })
+ }
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+
+
+
+
+
+
+ {phoneError && (
+
+ )}
+
+ {/* Available Numbers List */}
+ {availableNumbers.length > 0 ? (
+
+ {availableNumbers.map((num) => (
+
+
+
+ {formatPhoneNumber(num.phone_number)}
+
+
+ {num.locality}, {num.region}
+
+
+ {num.capabilities.voice && (
+
+ Voice
+
+ )}
+ {num.capabilities.sms && (
+
+ SMS
+
+ )}
+
+
+
+
+ ))}
+
+ ) : searchPhoneNumbers.isPending ? (
+
+
+
Searching for available numbers...
+
+ ) : (
+
+
+
+ Enter an area code and click Search to find available numbers
+
+
+ )}
+
+
+
+ )}
+
+ {/* Release Number Confirmation Modal */}
+ {numberToRelease && (
+
+
+
+ Release Phone Number?
+
+
+ Are you sure you want to release {formatPhoneNumber(numberToRelease.phone_number)}?
+ This will permanently remove the number from your account.
+
+
+ {phoneError && (
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* Wizard Phone Number Selection Modal */}
+ {showWizardPhoneModal && (
+
+
+
+
+
+
+ Choose Your Phone Number
+
+
+ Select a phone number for SMS and calling
+
+
+
+
+
+ {/* Selected number indicator */}
+ {wizardSelectedNumber && (
+
+
+ Selected:
+
+
+ {formatPhoneNumber(wizardSelectedNumber.phone_number)}
+
+
+
+ )}
+
+ {/* Search controls */}
+
+
+
+
+ setWizardPhoneSearchQuery({ ...wizardPhoneSearchQuery, area_code: e.target.value.replace(/\D/g, '') })
+ }
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
+ />
+
+
+
+
+ setWizardPhoneSearchQuery({ ...wizardPhoneSearchQuery, contains: e.target.value.replace(/\D/g, '') })
+ }
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
+ />
+
+
+
+
+
+
+
+ {/* Search results */}
+
+ {phoneError && (
+
+ )}
+
+ {wizardAvailableNumbers.length > 0 ? (
+
+ {wizardAvailableNumbers.map((num) => {
+ const isSelected = wizardSelectedNumber?.phone_number === num.phone_number;
+ return (
+
+
+
+ {formatPhoneNumber(num.phone_number)}
+
+
+ {num.locality}, {num.region}
+
+
+
+
+ $2.00/mo
+
+
+
+
+ );
+ })}
+
+ ) : searchPhoneNumbers.isPending ? (
+
+
+
Searching for available numbers...
+
+ ) : (
+
+
+
+ Enter an area code and click Search to find available numbers
+
+
+ )}
+
+
+ {/* Footer with action buttons */}
+ {/* TODO: Phone number availability is limited. Future implementation will need a phone app
+ to handle cases where we can't provision enough Twilio numbers for all staff.
+ Consider: SIP trunking, shared number pools, or mobile app with VoIP integration.
+
+ TODO: Premium Dedicated Number Service
+ - Allow businesses to request their own dedicated caller ID/number (not from pool)
+ - Premium service: purchase number from another carrier and port to Twilio
+ - Requires support ticket submission (manual process with human interaction)
+ - Higher monthly fee for dedicated numbers
+ - Add FAQ entry explaining this premium option and how to request it */}
+
+
+
+ {wizardSelectedNumber ? `Monthly cost: ${formatCurrency(200)}` : 'Select a phone number'}
+
+
+
+
+
+
+
+
+
+ )}
);
diff --git a/frontend/src/pages/settings/DomainsSettings.tsx b/frontend/src/pages/settings/CustomDomainsSettings.tsx
similarity index 80%
rename from frontend/src/pages/settings/DomainsSettings.tsx
rename to frontend/src/pages/settings/CustomDomainsSettings.tsx
index cbc4431..1e2c97e 100644
--- a/frontend/src/pages/settings/DomainsSettings.tsx
+++ b/frontend/src/pages/settings/CustomDomainsSettings.tsx
@@ -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 (
{/* Header */}
- {t('settings.domains.title', 'Custom Domains')}
+ {t('settings.customDomains.title', 'Custom Domains')}
- Configure custom domains for your booking pages.
+ Use your own domains for your booking pages.
-
- {/* Quick Domain Setup - Booking URL */}
-
-
- Your Booking URL
-
-
-
- {business.subdomain}.smoothschedule.com
-
-
-
-
-
- {/* Custom Domains Management */}
- {business.plan !== 'Free' ? (
- <>
+ {/* Custom Domains Management - with overlay when locked */}
+
+ {isCustomDomainLocked && (
+
+
+
+ Upgrade to Enable Custom Domains
+
+
+
+ )}
+
- Custom Domains
+ Bring Your Own Domain
- Use your own domains for your booking pages
+ Connect a domain you already own
@@ -288,7 +281,7 @@ const DomainsSettings: React.FC = () => {
{/* Domain Purchase */}
-
+
@@ -302,26 +295,8 @@ const DomainsSettings: React.FC = () => {
- >
- ) : (
- /* Upgrade prompt for free plans */
-
-
-
-
-
-
-
Unlock Custom Domains
-
- Upgrade to use your own domain (e.g., book.yourbusiness.com) or purchase a new one.
-
-
-
-
-
- )}
+
+
{/* Toast */}
{showToast && (
@@ -330,9 +305,8 @@ const DomainsSettings: React.FC = () => {
Changes saved successfully
)}
-
);
};
-export default DomainsSettings;
+export default CustomDomainsSettings;
diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx
index 6363d50..98323f2 100644
--- a/frontend/src/pages/settings/GeneralSettings.tsx
+++ b/frontend/src/pages/settings/GeneralSettings.tsx
@@ -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
) => {
+ const handleChange = (e: React.ChangeEvent) => {
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 = () => {
+ {/* Timezone Settings */}
+
+
+
+ {t('settings.timezone.title', 'Timezone Settings')}
+
+
+
+
+
+
+ {t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
+
+
+
+
+
+
+ {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.')}
+
+
+
+
+
{/* Contact Information */}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index f3800d7..9f68c84 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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;
customerDashboardContent?: PageComponent[];
diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts
new file mode 100644
index 0000000..e375083
--- /dev/null
+++ b/frontend/src/utils/colorUtils.ts
@@ -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 {
+ 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): 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 = {
+ 50: '#eff6ff',
+ 100: '#dbeafe',
+ 200: '#bfdbfe',
+ 300: '#93c5fd',
+ 400: '#60a5fa',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ 800: '#1e40af',
+ 900: '#1e3a8a',
+};
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 9bc68e8..3e2ef44 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -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)',
},
},
},
diff --git a/smoothschedule/.envs/.local/.django b/smoothschedule/.envs/.local/.django
index 70f15c0..dda67e7 100644
--- a/smoothschedule/.envs/.local/.django
+++ b/smoothschedule/.envs/.local/.django
@@ -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
diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py
index 5d4d739..655c0cb 100644
--- a/smoothschedule/config/settings/base.py
+++ b/smoothschedule/config/settings/base.py
@@ -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",
diff --git a/smoothschedule/core/migrations/0019_add_timezone_fields.py b/smoothschedule/core/migrations/0019_add_timezone_fields.py
new file mode 100644
index 0000000..65dc220
--- /dev/null
+++ b/smoothschedule/core/migrations/0019_add_timezone_fields.py
@@ -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),
+ ),
+ ]
diff --git a/smoothschedule/core/migrations/0020_booking_return_url.py b/smoothschedule/core/migrations/0020_booking_return_url.py
new file mode 100644
index 0000000..a605b1d
--- /dev/null
+++ b/smoothschedule/core/migrations/0020_booking_return_url.py
@@ -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),
+ ),
+ ]
diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py
index 3c9b34e..44a99ab 100644
--- a/smoothschedule/core/models.py
+++ b/smoothschedule/core/models.py
@@ -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,
diff --git a/smoothschedule/schedule/api_views.py b/smoothschedule/schedule/api_views.py
index 4cb9b6d..f82bf8e 100644
--- a/smoothschedule/schedule/api_views.py
+++ b/smoothschedule/schedule/api_views.py
@@ -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,
diff --git a/smoothschedule/smoothschedule/comms_credits/urls.py b/smoothschedule/smoothschedule/comms_credits/urls.py
index 6c0c12d..0a4ba8e 100644
--- a/smoothschedule/smoothschedule/comms_credits/urls.py
+++ b/smoothschedule/smoothschedule/comms_credits/urls.py
@@ -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//', release_phone_number_view, name='release_phone_number'),
+ path('phone-numbers//change/', change_phone_number_view, name='change_phone_number'),
]
diff --git a/smoothschedule/smoothschedule/comms_credits/views.py b/smoothschedule/smoothschedule/comms_credits/views.py
index b344b8b..27572ab 100644
--- a/smoothschedule/smoothschedule/comms_credits/views.py
+++ b/smoothschedule/smoothschedule/comms_credits/views.py
@@ -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
+ )