diff --git a/frontend/.env.development b/frontend/.env.development index 65ecb62..860e33d 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 75d4385..5435406 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/CreditPaymentForm.tsx b/frontend/src/components/CreditPaymentForm.tsx index d75b761..7651a8d 100644 --- a/frontend/src/components/CreditPaymentForm.tsx +++ b/frontend/src/components/CreditPaymentForm.tsx @@ -37,6 +37,7 @@ const PaymentFormInner: React.FC = ({ const [isProcessing, setIsProcessing] = useState(false); const [errorMessage, setErrorMessage] = useState(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 = ({
- + {!isElementReady && ( +
+ + Loading payment form... +
+ )} +
+ setIsElementReady(true)} + options={{ + layout: 'tabs', + }} + /> +
{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 = ({ @@ -175,11 +186,13 @@ export const CreditPaymentModal: React.FC = ({ amountCents, onAmountChange, savePaymentMethod = false, + skipAmountSelection = false, }) => { const [clientSecret, setClientSecret] = useState(null); const [isLoadingIntent, setIsLoadingIntent] = useState(false); const [error, setError] = useState(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 = ({ 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 = ({ return (
-
+
-

- Add Credits -

+
+

+ {skipAmountSelection ? 'Complete Payment' : 'Add Credits'} +

+

+ {skipAmountSelection + ? `Loading ${formatCurrency(amountCents)} to your balance` + : 'Choose an amount to add to your balance' + } +

+
- {!showPaymentForm ? ( + {/* Loading state when auto-initializing */} + {skipAmountSelection && isLoadingIntent && !clientSecret ? ( +
+ +

Setting up payment...

+
+ ) : skipAmountSelection && error && !clientSecret ? (
+
+ +

{error}

+
+
+ + +
+
+ ) : !showPaymentForm && !skipAmountSelection ? ( +
+
{[1000, 2500, 5000].map((amount) => ( + + + +
+

+ 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) +

+
+ +
+
+ +
+ + +
+
+ +
+
+ + + setWizardData({ ...wizardData, staffCount: parseInt(e.target.value) || 1 }) + } + 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" + /> +
+ {wizardData.callingPattern === 'concurrent' && ( +
+ + + setWizardData({ ...wizardData, maxDailyAppointmentsPerStaff: parseInt(e.target.value) || 1 }) + } + 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" + /> +
+ )} +
+ + {/* 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 && ( +
+ +

{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 && ( +
+ +

{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 && ( +
+ +

{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 + )