- 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>
327 lines
9.0 KiB
TypeScript
327 lines
9.0 KiB
TypeScript
/**
|
|
* Communication Credits Hooks
|
|
* For managing business SMS/calling credits and auto-reload settings
|
|
*/
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import apiClient from '../api/client';
|
|
|
|
export interface CommunicationCredits {
|
|
id: number;
|
|
balance_cents: number;
|
|
auto_reload_enabled: boolean;
|
|
auto_reload_threshold_cents: number;
|
|
auto_reload_amount_cents: number;
|
|
low_balance_warning_cents: number;
|
|
low_balance_warning_sent: boolean;
|
|
stripe_payment_method_id: string;
|
|
last_twilio_sync_at: string | null;
|
|
total_loaded_cents: number;
|
|
total_spent_cents: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface CreditTransaction {
|
|
id: number;
|
|
amount_cents: number;
|
|
balance_after_cents: number;
|
|
transaction_type: 'manual' | 'auto_reload' | 'usage' | 'refund' | 'adjustment' | 'promo';
|
|
description: string;
|
|
reference_type: string;
|
|
reference_id: string;
|
|
stripe_charge_id: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface UpdateCreditsSettings {
|
|
auto_reload_enabled?: boolean;
|
|
auto_reload_threshold_cents?: number;
|
|
auto_reload_amount_cents?: number;
|
|
low_balance_warning_cents?: number;
|
|
stripe_payment_method_id?: string;
|
|
}
|
|
|
|
export interface AddCreditsRequest {
|
|
amount_cents: number;
|
|
payment_method_id?: string;
|
|
}
|
|
|
|
/**
|
|
* Hook to get communication credits for current business
|
|
*/
|
|
export const useCommunicationCredits = () => {
|
|
return useQuery<CommunicationCredits>({
|
|
queryKey: ['communicationCredits'],
|
|
queryFn: async () => {
|
|
const { data } = await apiClient.get('/communication-credits/');
|
|
return data;
|
|
},
|
|
staleTime: 30 * 1000, // 30 seconds
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to get credit transaction history
|
|
*/
|
|
export const useCreditTransactions = (page = 1, limit = 20) => {
|
|
return useQuery<{ results: CreditTransaction[]; count: number; next: string | null; previous: string | null }>({
|
|
queryKey: ['creditTransactions', page, limit],
|
|
queryFn: async () => {
|
|
const { data } = await apiClient.get('/communication-credits/transactions/', {
|
|
params: { page, limit },
|
|
});
|
|
return data;
|
|
},
|
|
staleTime: 30 * 1000,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to update credit settings (auto-reload, thresholds, etc.)
|
|
*/
|
|
export const useUpdateCreditsSettings = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (settings: UpdateCreditsSettings) => {
|
|
const { data } = await apiClient.patch('/communication-credits/settings/', settings);
|
|
return data;
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.setQueryData(['communicationCredits'], data);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to add credits (manual top-up)
|
|
*/
|
|
export const useAddCredits = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (request: AddCreditsRequest) => {
|
|
const { data } = await apiClient.post('/communication-credits/add/', request);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
|
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to create a payment intent for credit purchase
|
|
*/
|
|
export const useCreatePaymentIntent = () => {
|
|
return useMutation({
|
|
mutationFn: async (amount_cents: number) => {
|
|
const { data } = await apiClient.post('/communication-credits/create-payment-intent/', {
|
|
amount_cents,
|
|
});
|
|
return data as { client_secret: string; payment_intent_id: string };
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to confirm a payment after client-side processing
|
|
*/
|
|
export const useConfirmPayment = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (params: { payment_intent_id: string; save_payment_method?: boolean }) => {
|
|
const { data } = await apiClient.post('/communication-credits/confirm-payment/', params);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
|
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to set up Stripe payment method for auto-reload
|
|
*/
|
|
export const useSetupPaymentMethod = () => {
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await apiClient.post('/communication-credits/setup-payment-method/');
|
|
return data as { client_secret: string };
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to save a payment method after setup
|
|
*/
|
|
export const useSavePaymentMethod = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (payment_method_id: string) => {
|
|
const { data } = await apiClient.post('/communication-credits/save-payment-method/', {
|
|
payment_method_id,
|
|
});
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to get communication usage stats
|
|
*/
|
|
export const useCommunicationUsageStats = () => {
|
|
return useQuery<{
|
|
sms_sent_this_month: number;
|
|
voice_minutes_this_month: number;
|
|
proxy_numbers_active: number;
|
|
estimated_cost_cents: number;
|
|
}>({
|
|
queryKey: ['communicationUsageStats'],
|
|
queryFn: async () => {
|
|
const { data } = await apiClient.get('/communication-credits/usage-stats/');
|
|
return data;
|
|
},
|
|
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'] });
|
|
},
|
|
});
|
|
};
|