Consolidate white_label to remove_branding and add embed widget

- Rename white_label feature to remove_branding across frontend/backend
- Update billing catalog, plan features, and permission checks
- Add dark mode support to Recharts tooltips with useDarkMode hook
- Create embeddable booking widget with EmbedBooking page
- Add EmbedWidgetSettings for generating embed code
- Fix Appearance settings page permission check
- Update test files for new feature naming
- Add notes field to User model

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-16 21:20:17 -05:00
parent 73d2bee01a
commit 6a6ad63e7b
31 changed files with 2115 additions and 181 deletions

View File

@@ -2,7 +2,7 @@
* Customer Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Customer } from '../types';
@@ -11,8 +11,77 @@ interface CustomerFilters {
search?: string;
}
interface PaginatedResponse {
count: number;
next: string | null;
previous: string | null;
results: any[];
}
/**
* Hook to fetch customers with optional filters
* Transform backend customer data to frontend format
*/
const transformCustomer = (c: any): Customer => ({
id: String(c.id),
name: c.name || c.user?.name || '',
email: c.email || c.user?.email || '',
phone: c.phone || '',
city: c.city,
state: c.state,
zip: c.zip,
totalSpend: parseFloat(c.total_spend || 0),
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
status: c.status,
avatarUrl: c.avatar_url,
tags: c.tags || [],
userId: String(c.user_id || c.user),
paymentMethods: [],
user_data: c.user_data,
notes: c.notes || '',
});
/**
* Hook to fetch customers with infinite scroll pagination
*/
export const useCustomersInfinite = (filters?: CustomerFilters) => {
return useInfiniteQuery<PaginatedResponse>({
queryKey: ['customers', 'infinite', filters || {}],
queryFn: async ({ pageParam = 1 }) => {
const params = new URLSearchParams();
params.append('page', String(pageParam));
params.append('page_size', '25');
if (filters?.status) params.append('status', filters.status);
if (filters?.search) params.append('search', filters.search);
const { data } = await apiClient.get(`/customers/?${params}`);
// Handle both paginated and non-paginated responses
if (Array.isArray(data)) {
return {
count: data.length,
next: null,
previous: null,
results: data,
};
}
return data;
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.next) {
// Extract page number from next URL
const url = new URL(lastPage.next);
const page = url.searchParams.get('page');
return page ? parseInt(page, 10) : undefined;
}
return undefined;
},
retry: false,
});
};
/**
* Hook to fetch all customers (non-paginated, for backward compatibility)
*/
export const useCustomers = (filters?: CustomerFilters) => {
return useQuery<Customer[]>({
@@ -24,29 +93,25 @@ export const useCustomers = (filters?: CustomerFilters) => {
const { data } = await apiClient.get(`/customers/?${params}`);
// Transform backend format to frontend format
return data.map((c: any) => ({
id: String(c.id),
name: c.name || c.user?.name || '',
email: c.email || c.user?.email || '',
phone: c.phone || '',
city: c.city,
state: c.state,
zip: c.zip,
totalSpend: parseFloat(c.total_spend || 0),
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
status: c.status,
avatarUrl: c.avatar_url,
tags: c.tags || [],
userId: String(c.user_id || c.user),
paymentMethods: [], // Will be populated when payment feature is implemented
user_data: c.user_data, // Include user_data for masquerading
}));
// Handle paginated response
const results = data.results || data;
return results.map(transformCustomer);
},
retry: false, // Don't retry on 404 - endpoint may not exist yet
retry: false,
});
};
interface CreateCustomerData {
name?: string;
firstName?: string;
lastName?: string;
email: string;
phone?: string;
city?: string;
state?: string;
zip?: string;
}
/**
* Hook to create a customer
*/
@@ -54,16 +119,23 @@ export const useCreateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (customerData: Partial<Customer>) => {
mutationFn: async (customerData: CreateCustomerData) => {
// Parse name into first_name and last_name if provided as single field
let firstName = customerData.firstName;
let lastName = customerData.lastName;
if (customerData.name && !firstName && !lastName) {
const nameParts = customerData.name.trim().split(/\s+/);
firstName = nameParts[0] || '';
lastName = nameParts.slice(1).join(' ') || '';
}
const backendData = {
user: customerData.userId ? parseInt(customerData.userId) : undefined,
phone: customerData.phone,
city: customerData.city,
state: customerData.state,
zip: customerData.zip,
status: customerData.status,
avatar_url: customerData.avatarUrl,
tags: customerData.tags,
first_name: firstName,
last_name: lastName,
email: customerData.email,
phone: customerData.phone || '',
// Note: city, state, zip are TODO in backend - not stored yet
};
const { data } = await apiClient.post('/customers/', backendData);
@@ -75,6 +147,16 @@ export const useCreateCustomer = () => {
});
};
interface UpdateCustomerData {
name?: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
isActive?: boolean;
notes?: string;
}
/**
* Hook to update a customer
*/
@@ -82,16 +164,25 @@ export const useUpdateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Customer> }) => {
const backendData = {
phone: updates.phone,
city: updates.city,
state: updates.state,
zip: updates.zip,
status: updates.status,
avatar_url: updates.avatarUrl,
tags: updates.tags,
};
mutationFn: async ({ id, updates }: { id: string; updates: UpdateCustomerData }) => {
// Parse name into first_name and last_name if provided as single field
let firstName = updates.firstName;
let lastName = updates.lastName;
if (updates.name && !firstName && !lastName) {
const nameParts = updates.name.trim().split(/\s+/);
firstName = nameParts[0] || '';
lastName = nameParts.slice(1).join(' ') || '';
}
const backendData: Record<string, unknown> = {};
if (firstName !== undefined) backendData.first_name = firstName;
if (lastName !== undefined) backendData.last_name = lastName;
if (updates.email !== undefined) backendData.email = updates.email;
if (updates.phone !== undefined) backendData.phone = updates.phone;
if (updates.isActive !== undefined) backendData.is_active = updates.isActive;
if (updates.notes !== undefined) backendData.notes = updates.notes;
const { data } = await apiClient.patch(`/customers/${id}/`, backendData);
return data;