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:
@@ -119,7 +119,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: true,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -160,7 +160,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -229,7 +229,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: true,
|
||||
api_access: true,
|
||||
custom_domain: true,
|
||||
white_label: true,
|
||||
remove_branding: true,
|
||||
custom_oauth: true,
|
||||
automations: true,
|
||||
can_create_automations: true,
|
||||
@@ -258,7 +258,7 @@ describe('usePlanFeatures', () => {
|
||||
expect(result.current.canUse('webhooks')).toBe(true);
|
||||
expect(result.current.canUse('api_access')).toBe(true);
|
||||
expect(result.current.canUse('custom_domain')).toBe(true);
|
||||
expect(result.current.canUse('white_label')).toBe(true);
|
||||
expect(result.current.canUse('remove_branding')).toBe(true);
|
||||
expect(result.current.canUse('custom_oauth')).toBe(true);
|
||||
expect(result.current.canUse('automations')).toBe(true);
|
||||
expect(result.current.canUse('tasks')).toBe(true);
|
||||
@@ -286,7 +286,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -326,7 +326,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -365,7 +365,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: true,
|
||||
api_access: true,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -404,7 +404,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: true,
|
||||
api_access: true,
|
||||
custom_domain: true,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -446,7 +446,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: true,
|
||||
api_access: true,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -486,7 +486,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: true,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -526,7 +526,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -565,7 +565,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -606,7 +606,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -649,7 +649,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -749,7 +749,7 @@ describe('usePlanFeatures', () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
plugins: false,
|
||||
tasks: false,
|
||||
@@ -779,7 +779,7 @@ describe('FEATURE_NAMES', () => {
|
||||
'webhooks',
|
||||
'api_access',
|
||||
'custom_domain',
|
||||
'white_label',
|
||||
'remove_branding',
|
||||
'custom_oauth',
|
||||
'automations',
|
||||
'can_create_automations',
|
||||
@@ -805,7 +805,7 @@ describe('FEATURE_NAMES', () => {
|
||||
expect(FEATURE_NAMES.webhooks).toBe('Webhooks');
|
||||
expect(FEATURE_NAMES.api_access).toBe('API Access');
|
||||
expect(FEATURE_NAMES.custom_domain).toBe('Custom Domain');
|
||||
expect(FEATURE_NAMES.white_label).toBe('White Label');
|
||||
expect(FEATURE_NAMES.remove_branding).toBe('Remove Branding');
|
||||
expect(FEATURE_NAMES.custom_oauth).toBe('Custom OAuth');
|
||||
expect(FEATURE_NAMES.automations).toBe('Automations');
|
||||
expect(FEATURE_NAMES.can_create_automations).toBe('Custom Automation Creation');
|
||||
@@ -827,7 +827,7 @@ describe('FEATURE_DESCRIPTIONS', () => {
|
||||
'webhooks',
|
||||
'api_access',
|
||||
'custom_domain',
|
||||
'white_label',
|
||||
'remove_branding',
|
||||
'custom_oauth',
|
||||
'automations',
|
||||
'can_create_automations',
|
||||
@@ -853,7 +853,7 @@ describe('FEATURE_DESCRIPTIONS', () => {
|
||||
expect(FEATURE_DESCRIPTIONS.webhooks).toContain('webhooks');
|
||||
expect(FEATURE_DESCRIPTIONS.api_access).toContain('API');
|
||||
expect(FEATURE_DESCRIPTIONS.custom_domain).toContain('custom domain');
|
||||
expect(FEATURE_DESCRIPTIONS.white_label).toContain('branding');
|
||||
expect(FEATURE_DESCRIPTIONS.remove_branding).toContain('branding');
|
||||
expect(FEATURE_DESCRIPTIONS.custom_oauth).toContain('OAuth');
|
||||
expect(FEATURE_DESCRIPTIONS.automations).toContain('Automate');
|
||||
expect(FEATURE_DESCRIPTIONS.can_create_automations).toContain('automations');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { format } from 'date-fns';
|
||||
|
||||
interface AppointmentFilters {
|
||||
resource?: string;
|
||||
customer?: string;
|
||||
status?: AppointmentStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
@@ -23,6 +24,7 @@ export const useAppointments = (filters?: AppointmentFilters) => {
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.resource) params.append('resource', filters.resource);
|
||||
if (filters?.customer) params.append('customer', filters.customer);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
// Send full ISO datetime strings to avoid timezone issues
|
||||
// The backend will compare datetime fields properly
|
||||
|
||||
@@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
|
||||
// Platform
|
||||
can_api_access: 'api_access',
|
||||
can_use_custom_domain: 'custom_domain',
|
||||
can_white_label: 'white_label',
|
||||
can_white_label: 'remove_branding',
|
||||
|
||||
// Features
|
||||
can_accept_payments: 'payment_processing',
|
||||
@@ -328,11 +328,8 @@ export function planFeaturesToLegacyPermissions(
|
||||
case 'custom_domain':
|
||||
permissions.can_use_custom_domain = value as boolean;
|
||||
break;
|
||||
case 'white_label':
|
||||
permissions.can_white_label = value as boolean;
|
||||
break;
|
||||
case 'remove_branding':
|
||||
permissions.can_white_label = permissions.can_white_label || (value as boolean);
|
||||
permissions.can_white_label = value as boolean;
|
||||
break;
|
||||
case 'payment_processing':
|
||||
permissions.can_accept_payments = value as boolean;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const useCurrentBusiness = () => {
|
||||
webhooks: false,
|
||||
api_access: false,
|
||||
custom_domain: false,
|
||||
white_label: false,
|
||||
remove_branding: false,
|
||||
custom_oauth: false,
|
||||
automations: false,
|
||||
can_create_automations: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
frontend/src/hooks/useDarkMode.ts
Normal file
49
frontend/src/hooks/useDarkMode.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if dark mode is currently active.
|
||||
* Watches for changes to the 'dark' class on documentElement.
|
||||
*/
|
||||
export const useDarkMode = (): boolean => {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tooltip styles for Recharts that respond to dark mode.
|
||||
* Uses darker colors for better contrast against chart backgrounds.
|
||||
*/
|
||||
export const getChartTooltipStyles = (isDark: boolean) => ({
|
||||
contentStyle: {
|
||||
borderRadius: '8px',
|
||||
border: isDark ? 'none' : '1px solid #E5E7EB',
|
||||
boxShadow: isDark
|
||||
? '0 4px 6px -1px rgb(0 0 0 / 0.4)'
|
||||
: '0 4px 6px -1px rgb(0 0 0 / 0.15)',
|
||||
backgroundColor: isDark ? '#0F172A' : '#F9FAFB', // gray-900/slate-900 for dark, gray-50 for light
|
||||
color: isDark ? '#F3F4F6' : '#111827',
|
||||
},
|
||||
});
|
||||
|
||||
export default useDarkMode;
|
||||
@@ -101,7 +101,7 @@ export const FEATURE_CODES = {
|
||||
// Boolean features (permissions)
|
||||
CAN_ACCEPT_PAYMENTS: 'can_accept_payments',
|
||||
CAN_USE_CUSTOM_DOMAIN: 'can_use_custom_domain',
|
||||
CAN_WHITE_LABEL: 'can_white_label',
|
||||
CAN_REMOVE_BRANDING: 'can_white_label', // Backend field still named can_white_label
|
||||
CAN_API_ACCESS: 'can_api_access',
|
||||
CAN_USE_SMS_REMINDERS: 'can_use_sms_reminders',
|
||||
CAN_USE_MASKED_PHONE_NUMBERS: 'can_use_masked_phone_numbers',
|
||||
|
||||
@@ -81,7 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
||||
webhooks: 'Webhooks',
|
||||
api_access: 'API Access',
|
||||
custom_domain: 'Custom Domain',
|
||||
white_label: 'White Label',
|
||||
remove_branding: 'Remove Branding',
|
||||
custom_oauth: 'Custom OAuth',
|
||||
automations: 'Automations',
|
||||
can_create_automations: 'Custom Automation Creation',
|
||||
@@ -104,7 +104,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
||||
webhooks: 'Integrate with external services using webhooks',
|
||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||
custom_domain: 'Use your own custom domain for your booking site',
|
||||
white_label: 'Remove SmoothSchedule branding and use your own',
|
||||
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||
automations: 'Automate repetitive tasks with custom workflows',
|
||||
can_create_automations: 'Create custom automations tailored to your business needs',
|
||||
|
||||
Reference in New Issue
Block a user