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:
@@ -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
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -37,6 +37,7 @@ const PaymentFormInner: React.FC<PaymentFormProps> = ({
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [isElementReady, setIsElementReady] = useState(false);
|
||||
const confirmPayment = useConfirmPayment();
|
||||
|
||||
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
|
||||
@@ -110,11 +111,20 @@ const PaymentFormInner: React.FC<PaymentFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
}}
|
||||
/>
|
||||
{!isElementReady && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading payment form...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={isElementReady ? '' : 'hidden'}>
|
||||
<PaymentElement
|
||||
onReady={() => setIsElementReady(true)}
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
@@ -164,8 +174,9 @@ interface CreditPaymentModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
amountCents: number;
|
||||
onAmountChange: (cents: number) => void;
|
||||
onAmountChange?: (cents: number) => void;
|
||||
savePaymentMethod?: boolean;
|
||||
skipAmountSelection?: boolean;
|
||||
}
|
||||
|
||||
export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
@@ -175,11 +186,13 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
amountCents,
|
||||
onAmountChange,
|
||||
savePaymentMethod = false,
|
||||
skipAmountSelection = false,
|
||||
}) => {
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [isLoadingIntent, setIsLoadingIntent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPaymentForm, setShowPaymentForm] = useState(false);
|
||||
const [autoInitialized, setAutoInitialized] = useState(false);
|
||||
const createPaymentIntent = useCreatePaymentIntent();
|
||||
|
||||
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
|
||||
@@ -189,9 +202,18 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
setClientSecret(null);
|
||||
setShowPaymentForm(false);
|
||||
setError(null);
|
||||
setAutoInitialized(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-initialize payment when skipping amount selection
|
||||
useEffect(() => {
|
||||
if (isOpen && skipAmountSelection && !autoInitialized && !isLoadingIntent && !clientSecret) {
|
||||
setAutoInitialized(true);
|
||||
handleContinueToPayment();
|
||||
}
|
||||
}, [isOpen, skipAmountSelection, autoInitialized, isLoadingIntent, clientSecret]);
|
||||
|
||||
const handleContinueToPayment = async () => {
|
||||
setIsLoadingIntent(true);
|
||||
setError(null);
|
||||
@@ -211,11 +233,19 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Add Credits
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{skipAmountSelection ? 'Complete Payment' : 'Add Credits'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{skipAmountSelection
|
||||
? `Loading ${formatCurrency(amountCents)} to your balance`
|
||||
: 'Choose an amount to add to your balance'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@@ -224,13 +254,46 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showPaymentForm ? (
|
||||
{/* Loading state when auto-initializing */}
|
||||
{skipAmountSelection && isLoadingIntent && !clientSecret ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Setting up payment...</p>
|
||||
</div>
|
||||
) : skipAmountSelection && error && !clientSecret ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoInitialized(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : !showPaymentForm && !skipAmountSelection ? (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Quick select
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1000, 2500, 5000].map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => onAmountChange(amount)}
|
||||
onClick={() => onAmountChange?.(amount)}
|
||||
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
|
||||
amountCents === amount
|
||||
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
|
||||
@@ -255,7 +318,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
value={amountCents / 100}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||||
onAmountChange(Math.max(5, parseInt(val) || 5) * 100);
|
||||
onAmountChange?.(Math.max(5, parseInt(val) || 5) * 100);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, LucideIcon } from 'lucide-react';
|
||||
import { ChevronDown, Lock, LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SidebarSectionProps {
|
||||
title?: string;
|
||||
@@ -48,6 +48,7 @@ interface SidebarItemProps {
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
variant?: 'default' | 'settings';
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
disabled = false,
|
||||
badge,
|
||||
variant = 'default',
|
||||
locked = false,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = exact
|
||||
@@ -75,10 +77,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
const colorClasses = variant === 'settings'
|
||||
? isActive
|
||||
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
: locked
|
||||
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
: isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
: locked
|
||||
? 'text-white/40 hover:text-white/60 hover:bg-white/5'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
|
||||
const disabledClasses = variant === 'settings'
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
@@ -101,7 +107,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
return (
|
||||
<Link to={to} className={className} title={label}>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||
{!isCollapsed && (
|
||||
<span className="flex-1 flex items-center gap-1.5">
|
||||
{label}
|
||||
{locked && <Lock size={12} className="opacity-60" />}
|
||||
</span>
|
||||
)}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
@@ -244,16 +255,18 @@ interface SettingsSidebarItemProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings navigation item with optional description
|
||||
* Settings navigation item with optional description and lock indicator
|
||||
*/
|
||||
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
locked = false,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
|
||||
@@ -264,12 +277,19 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
className={`flex items-start gap-2.5 px-4 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
: locked
|
||||
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-400 dark:hover:bg-gray-800'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{label}</span>
|
||||
{locked && (
|
||||
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
|
||||
{description}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,88 +15,7 @@ import { useTicket } from '../hooks/useTickets';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
import { SandboxProvider, useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
/**
|
||||
* Convert a hex color to HSL values
|
||||
*/
|
||||
function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
// Remove # if present
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
// Parse hex values
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL values to hex color
|
||||
*/
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
|
||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a color palette from a base color
|
||||
*/
|
||||
function generateColorPalette(baseColor: string): Record<string, string> {
|
||||
const { h, s } = hexToHSL(baseColor);
|
||||
|
||||
return {
|
||||
50: hslToHex(h, Math.min(s, 30), 97),
|
||||
100: hslToHex(h, Math.min(s, 40), 94),
|
||||
200: hslToHex(h, Math.min(s, 50), 86),
|
||||
300: hslToHex(h, Math.min(s, 60), 74),
|
||||
400: hslToHex(h, Math.min(s, 70), 60),
|
||||
500: hslToHex(h, s, 50),
|
||||
600: baseColor, // Use the exact primary color for 600
|
||||
700: hslToHex(h, s, 40),
|
||||
800: hslToHex(h, s, 32),
|
||||
900: hslToHex(h, s, 24),
|
||||
};
|
||||
}
|
||||
import { applyColorPalette, applyBrandColors, defaultColorPalette } from '../utils/colorUtils';
|
||||
|
||||
interface BusinessLayoutProps {
|
||||
business: Business;
|
||||
@@ -145,37 +64,19 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
||||
setTicketModalId(null);
|
||||
};
|
||||
|
||||
// Generate brand color palette from business primary color
|
||||
const brandPalette = useMemo(() => {
|
||||
return generateColorPalette(business.primaryColor || '#2563eb');
|
||||
}, [business.primaryColor]);
|
||||
|
||||
// Set CSS custom properties for brand colors
|
||||
// Set CSS custom properties for brand colors (primary palette + secondary color)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(brandPalette).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
applyBrandColors(
|
||||
business.primaryColor || '#2563eb',
|
||||
business.secondaryColor || business.primaryColor || '#2563eb'
|
||||
);
|
||||
|
||||
// Cleanup: reset to defaults when component unmounts
|
||||
return () => {
|
||||
const defaultColors: Record<string, string> = {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
};
|
||||
Object.entries(defaultColors).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
applyColorPalette(defaultColorPalette);
|
||||
document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9');
|
||||
};
|
||||
}, [brandPalette]);
|
||||
}, [business.primaryColor, business.secondaryColor]);
|
||||
|
||||
// Check for trial expiration and redirect
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
frontend/src/pages/settings/BookingSettings.tsx
Normal file
145
frontend/src/pages/settings/BookingSettings.tsx
Normal 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;
|
||||
@@ -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
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Domains Settings Page
|
||||
* Custom Domains Settings Page
|
||||
*
|
||||
* Manage custom domains and booking URLs for the business.
|
||||
* Manage custom domains - BYOD and domain purchase.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useOutletContext, Link } from 'react-router-dom';
|
||||
import {
|
||||
Globe, Link2, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
|
||||
ShoppingCart, Crown
|
||||
Globe, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
|
||||
ShoppingCart, Lock, ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
import { Business, User, CustomDomain } from '../../types';
|
||||
import {
|
||||
@@ -21,9 +21,8 @@ import {
|
||||
} from '../../hooks/useCustomDomains';
|
||||
import DomainPurchase from '../../components/DomainPurchase';
|
||||
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
|
||||
import { LockedSection } from '../../components/UpgradePrompt';
|
||||
|
||||
const DomainsSettings: React.FC = () => {
|
||||
const CustomDomainsSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
@@ -115,51 +114,45 @@ const DomainsSettings: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isCustomDomainLocked = !canUse('custom_domain');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Globe className="text-indigo-500" />
|
||||
{t('settings.domains.title', 'Custom Domains')}
|
||||
{t('settings.customDomains.title', 'Custom Domains')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure custom domains for your booking pages.
|
||||
Use your own domains for your booking pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LockedSection feature="custom_domain" isLocked={!canUse('custom_domain')}>
|
||||
{/* Quick Domain Setup - Booking URL */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Link2 size={20} className="text-brand-500" /> Your Booking URL
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{business.subdomain}.smoothschedule.com
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(`${business.subdomain}.smoothschedule.com`)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Custom Domains Management */}
|
||||
{business.plan !== 'Free' ? (
|
||||
<>
|
||||
{/* Custom Domains Management - with overlay when locked */}
|
||||
<div className="relative">
|
||||
{isCustomDomainLocked && (
|
||||
<div className="absolute inset-0 z-10 bg-white/70 dark:bg-gray-900/70 backdrop-blur-[2px] rounded-xl flex items-center justify-center">
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white font-semibold rounded-lg shadow-lg hover:from-amber-600 hover:to-orange-600 transition-all"
|
||||
>
|
||||
<Lock size={18} />
|
||||
Upgrade to Enable Custom Domains
|
||||
<ArrowUpRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={isCustomDomainLocked ? 'pointer-events-none select-none' : ''}>
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Globe size={20} className="text-indigo-500" />
|
||||
Custom Domains
|
||||
Bring Your Own Domain
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Use your own domains for your booking pages
|
||||
Connect a domain you already own
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,7 +281,7 @@ const DomainsSettings: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Domain Purchase */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
@@ -302,26 +295,8 @@ const DomainsSettings: React.FC = () => {
|
||||
</div>
|
||||
<DomainPurchase />
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
/* Upgrade prompt for free plans */
|
||||
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||
<Crown size={24} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Unlock Custom Domains</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
Upgrade to use your own domain (e.g., <span className="font-mono">book.yourbusiness.com</span>) or purchase a new one.
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
|
||||
<Crown size={16} /> View Plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
@@ -330,9 +305,8 @@ const DomainsSettings: React.FC = () => {
|
||||
Changes saved successfully
|
||||
</div>
|
||||
)}
|
||||
</LockedSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainsSettings;
|
||||
export default CustomDomainsSettings;
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
122
frontend/src/utils/colorUtils.ts
Normal file
122
frontend/src/utils/colorUtils.ts
Normal 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',
|
||||
};
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
23
smoothschedule/core/migrations/0019_add_timezone_fields.py
Normal file
23
smoothschedule/core/migrations/0019_add_timezone_fields.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
smoothschedule/core/migrations/0020_booking_return_url.py
Normal file
18
smoothschedule/core/migrations/0020_booking_return_url.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user