From 6a6ad63e7b55a12876f79d665d6565e4ea6628df Mon Sep 17 00:00:00 2001 From: poduck Date: Tue, 16 Dec 2025 21:20:17 -0500 Subject: [PATCH] Consolidate white_label to remove_branding and add embed widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 7 + frontend/src/billing/featureCatalog.ts | 6 +- .../__tests__/UpgradePrompt.test.tsx | 10 +- .../src/components/dashboard/ChartWidget.tsx | 23 +- .../dashboard/CustomerBreakdownWidget.tsx | 17 +- .../marketing/FeatureComparisonTable.tsx | 3 +- .../platform/FeaturesPermissionsEditor.tsx | 6 +- .../hooks/__tests__/usePlanFeatures.test.ts | 38 +- frontend/src/hooks/useAppointments.ts | 2 + frontend/src/hooks/useBillingPlans.ts | 7 +- frontend/src/hooks/useBusiness.ts | 2 +- frontend/src/hooks/useCustomers.ts | 171 +++- frontend/src/hooks/useDarkMode.ts | 49 ++ frontend/src/hooks/useEntitlements.ts | 2 +- frontend/src/hooks/usePlanFeatures.ts | 4 +- frontend/src/i18n/locales/en.json | 52 +- frontend/src/layouts/SettingsLayout.tsx | 11 +- .../layouts/__tests__/SettingsLayout.test.tsx | 8 +- frontend/src/pages/Customers.tsx | 591 ++++++++++++- frontend/src/pages/EmbedBooking.tsx | 819 ++++++++++++++++++ frontend/src/pages/StaffDashboard.tsx | 13 +- .../src/pages/platform/PlatformDashboard.tsx | 19 +- .../platform/components/TenantInviteModal.tsx | 6 +- .../pages/settings/EmbedWidgetSettings.tsx | 349 ++++++++ frontend/src/types.ts | 3 +- .../commands/billing_seed_catalog.py | 9 +- .../migrations/0013_add_notes_to_user.py | 18 + .../smoothschedule/identity/users/models.py | 5 + .../scheduling/schedule/api_views.py | 2 +- .../scheduling/schedule/serializers.py | 14 +- .../scheduling/schedule/views.py | 30 +- 31 files changed, 2115 insertions(+), 181 deletions(-) create mode 100644 frontend/src/hooks/useDarkMode.ts create mode 100644 frontend/src/pages/EmbedBooking.tsx create mode 100644 frontend/src/pages/settings/EmbedWidgetSettings.tsx create mode 100644 smoothschedule/smoothschedule/identity/users/migrations/0013_add_notes_to_user.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfa7f7fd..3c5e5046 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -134,6 +134,10 @@ const BillingSettings = React.lazy(() => import('./pages/settings/BillingSetting const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings')); const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings')); const StaffRolesSettings = React.lazy(() => import('./pages/settings/StaffRolesSettings')); +const EmbedWidgetSettings = React.lazy(() => import('./pages/settings/EmbedWidgetSettings')); + +// Embed pages +const EmbedBooking = React.lazy(() => import('./pages/EmbedBooking')); import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications @@ -358,6 +362,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> } /> } /> @@ -688,6 +693,7 @@ const AppContent: React.FC = () => { {/* Public routes outside BusinessLayout */} } /> } /> + } /> } /> } /> @@ -953,6 +959,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/billing/featureCatalog.ts b/frontend/src/billing/featureCatalog.ts index e9395c70..db98db96 100644 --- a/frontend/src/billing/featureCatalog.ts +++ b/frontend/src/billing/featureCatalog.ts @@ -224,9 +224,9 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ category: 'branding', }, { - code: 'white_label', - name: 'White Label', - description: 'Remove SmoothSchedule branding completely', + code: 'remove_branding', + name: 'Remove Branding', + description: 'Remove SmoothSchedule branding from customer-facing pages', type: 'boolean', category: 'branding', }, diff --git a/frontend/src/components/__tests__/UpgradePrompt.test.tsx b/frontend/src/components/__tests__/UpgradePrompt.test.tsx index 20d303ec..cede0c45 100644 --- a/frontend/src/components/__tests__/UpgradePrompt.test.tsx +++ b/frontend/src/components/__tests__/UpgradePrompt.test.tsx @@ -68,7 +68,7 @@ describe('UpgradePrompt', () => { }); it('should render for any feature in inline mode', () => { - const features: FeatureKey[] = ['plugins', 'custom_domain', 'white_label']; + const features: FeatureKey[] = ['plugins', 'custom_domain', 'remove_branding']; features.forEach((feature) => { const { unmount } = renderWithRouter( @@ -140,7 +140,7 @@ describe('UpgradePrompt', () => { 'webhooks', 'api_access', 'custom_domain', - 'white_label', + 'remove_branding', 'plugins', ]; @@ -243,7 +243,7 @@ describe('UpgradePrompt', () => { it('should make children non-interactive', () => { renderWithRouter( - + ); @@ -374,7 +374,7 @@ describe('LockedSection', () => { describe('Different Features', () => { it('should work with different feature keys', () => { const features: FeatureKey[] = [ - 'white_label', + 'remove_branding', 'custom_oauth', 'can_create_plugins', 'tasks', @@ -470,7 +470,7 @@ describe('LockedButton', () => { const handleClick = vi.fn(); renderWithRouter( diff --git a/frontend/src/components/dashboard/ChartWidget.tsx b/frontend/src/components/dashboard/ChartWidget.tsx index d07867a9..202cd2e3 100644 --- a/frontend/src/components/dashboard/ChartWidget.tsx +++ b/frontend/src/components/dashboard/ChartWidget.tsx @@ -11,6 +11,7 @@ import { Line, } from 'recharts'; import { GripVertical, X } from 'lucide-react'; +import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode'; interface ChartData { name: string; @@ -36,6 +37,8 @@ const ChartWidget: React.FC = ({ isEditing, onRemove, }) => { + const isDark = useDarkMode(); + const tooltipStyles = getChartTooltipStyles(isDark); const formatValue = (value: number) => `${valuePrefix}${value}`; return ( @@ -58,8 +61,8 @@ const ChartWidget: React.FC = ({ {title} -
- +
+ {type === 'bar' ? ( @@ -67,13 +70,7 @@ const ChartWidget: React.FC = ({ [formatValue(value), title]} /> @@ -84,13 +81,7 @@ const ChartWidget: React.FC = ({ [value, title]} /> diff --git a/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx b/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx index ffa15255..2ed9f6bc 100644 --- a/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx +++ b/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; import { Customer } from '../../types'; +import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode'; interface CustomerBreakdownWidgetProps { customers: Customer[]; @@ -16,6 +17,8 @@ const CustomerBreakdownWidget: React.FC = ({ onRemove, }) => { const { t } = useTranslation(); + const isDark = useDarkMode(); + const tooltipStyles = getChartTooltipStyles(isDark); const breakdownData = useMemo(() => { // Customers with lastVisit are returning, without are new const returning = customers.filter((c) => c.lastVisit !== null).length; @@ -57,8 +60,8 @@ const CustomerBreakdownWidget: React.FC = ({
{/* Pie Chart */} -
- +
+ = ({ ))} - +
diff --git a/frontend/src/components/marketing/FeatureComparisonTable.tsx b/frontend/src/components/marketing/FeatureComparisonTable.tsx index dbccbffc..e3b91944 100644 --- a/frontend/src/components/marketing/FeatureComparisonTable.tsx +++ b/frontend/src/components/marketing/FeatureComparisonTable.tsx @@ -53,8 +53,7 @@ const FEATURE_CATEGORIES = [ features: [ { code: 'custom_domain', label: 'Custom domain' }, { code: 'custom_branding', label: 'Custom branding' }, - { code: 'remove_branding', label: 'Remove "Powered by"' }, - { code: 'white_label', label: 'White label' }, + { code: 'remove_branding', label: 'Remove branding' }, ], }, { diff --git a/frontend/src/components/platform/FeaturesPermissionsEditor.tsx b/frontend/src/components/platform/FeaturesPermissionsEditor.tsx index 239e2885..77b77505 100644 --- a/frontend/src/components/platform/FeaturesPermissionsEditor.tsx +++ b/frontend/src/components/platform/FeaturesPermissionsEditor.tsx @@ -117,11 +117,11 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ category: 'customization', }, { - key: 'white_label', + key: 'remove_branding', planKey: 'can_white_label', businessKey: 'can_white_label', - label: 'White Labelling', - description: 'Remove SmoothSchedule branding', + label: 'Remove Branding', + description: 'Remove SmoothSchedule branding from customer-facing pages', category: 'customization', }, diff --git a/frontend/src/hooks/__tests__/usePlanFeatures.test.ts b/frontend/src/hooks/__tests__/usePlanFeatures.test.ts index 843fdbfb..e174edc4 100644 --- a/frontend/src/hooks/__tests__/usePlanFeatures.test.ts +++ b/frontend/src/hooks/__tests__/usePlanFeatures.test.ts @@ -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'); diff --git a/frontend/src/hooks/useAppointments.ts b/frontend/src/hooks/useAppointments.ts index 12a9d84e..7fe84098 100644 --- a/frontend/src/hooks/useAppointments.ts +++ b/frontend/src/hooks/useAppointments.ts @@ -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 diff --git a/frontend/src/hooks/useBillingPlans.ts b/frontend/src/hooks/useBillingPlans.ts index 1cc0a2f0..06c7f3f3 100644 --- a/frontend/src/hooks/useBillingPlans.ts +++ b/frontend/src/hooks/useBillingPlans.ts @@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record = { // 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; diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts index 322040d4..b94eb32d 100644 --- a/frontend/src/hooks/useBusiness.ts +++ b/frontend/src/hooks/useBusiness.ts @@ -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, diff --git a/frontend/src/hooks/useCustomers.ts b/frontend/src/hooks/useCustomers.ts index 255c3b11..3d3fc9a6 100644 --- a/frontend/src/hooks/useCustomers.ts +++ b/frontend/src/hooks/useCustomers.ts @@ -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({ + 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({ @@ -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) => { + 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 }) => { - 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 = {}; + + 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; diff --git a/frontend/src/hooks/useDarkMode.ts b/frontend/src/hooks/useDarkMode.ts new file mode 100644 index 00000000..d35a3c0e --- /dev/null +++ b/frontend/src/hooks/useDarkMode.ts @@ -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; diff --git a/frontend/src/hooks/useEntitlements.ts b/frontend/src/hooks/useEntitlements.ts index e13e09ac..5805637d 100644 --- a/frontend/src/hooks/useEntitlements.ts +++ b/frontend/src/hooks/useEntitlements.ts @@ -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', diff --git a/frontend/src/hooks/usePlanFeatures.ts b/frontend/src/hooks/usePlanFeatures.ts index 3288a1bf..d7123ecc 100644 --- a/frontend/src/hooks/usePlanFeatures.ts +++ b/frontend/src/hooks/usePlanFeatures.ts @@ -81,7 +81,7 @@ export const FEATURE_NAMES: Record = { 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 = { 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', diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 77a90c38..cc23b58a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -50,7 +50,9 @@ "optional": "Optional", "masquerade": "Masquerade", "masqueradeAsUser": "Masquerade as User", - "plan": "Plan" + "plan": "Plan", + "loadingMore": "Loading more...", + "minutes": "min" }, "auth": { "signIn": "Sign in", @@ -942,6 +944,7 @@ "contactInfo": "Contact Info", "status": "Status", "active": "Active", + "activeDescription": "Inactive customers cannot log in or book appointments.", "inactive": "Inactive", "never": "Never", "customer": "Customer", @@ -953,6 +956,22 @@ "errorLoading": "Error loading customers", "deleteCustomer": "Delete Customer", "deleteConfirmation": "Are you sure you want to delete this customer? This action cannot be undone.", + "totalAppointments": "Total Appointments", + "unknownService": "Unknown Service", + "backToList": "Back to appointments", + "date": "Date", + "time": "Time", + "notes": "Notes", + "noNotes": "No notes for this appointment", + "hasNotes": "Has notes", + "viewPastAppointments": "View past and upcoming appointments", + "customerNotes": "Customer Notes", + "hasCustomerNotes": "View or edit notes", + "noCustomerNotes": "No notes added yet", + "noNotesYet": "No notes have been added for this customer.", + "enterNotesPlaceholder": "Enter notes about this customer...", + "editNotes": "Edit Notes", + "addNotes": "Add Notes", "password": "Password", "newPassword": "New Password", "passwordPlaceholder": "Leave blank to keep current password", @@ -1354,6 +1373,34 @@ "title": "Custom Domains", "description": "Use your own domain" }, + "embedWidget": { + "title": "Embed Widget", + "sidebarDescription": "Add booking to your site", + "description": "Add a booking widget to your website or any third-party site", + "onlyOwnerCanAccess": "Only the business owner can access these settings.", + "paymentNotice": "Payment Handling", + "paymentNoticeText": "Services that require a deposit cannot be booked through the embedded widget due to payment security restrictions. Customers will be redirected to your main booking page for those services, or you can hide them from the widget entirely.", + "configuration": "Configuration", + "showPrices": "Show service prices", + "showDuration": "Show service duration", + "hideDeposits": "Hide services requiring deposits", + "hideDepositsHint": "Only show services that can be booked without payment", + "primaryColor": "Primary color", + "width": "Width", + "height": "Height (px)", + "preview": "Preview", + "openInNewTab": "Open in new tab", + "embedCode": "Embed Code", + "simpleCode": "Simple (iframe only)", + "fullCode": "With auto-resize", + "recommended": "Recommended", + "copy": "Copy", + "copied": "Copied!", + "howToUse": "How to Use", + "step1": "Configure the widget options above to match your website's style.", + "step2": "Copy the embed code and paste it into your website's HTML where you want the booking widget to appear.", + "step3": "For platforms like WordPress, Squarespace, or Wix, look for an \"HTML\" or \"Embed\" block and paste the code there." + }, "api": { "title": "API & Webhooks", "description": "API tokens, webhooks" @@ -2021,8 +2068,7 @@ "max_api_calls_per_day": "API calls/day", "custom_domain": "Custom domain", "custom_branding": "Custom branding", - "remove_branding": "Remove \"Powered by\"", - "white_label": "White label", + "remove_branding": "Remove branding", "multi_location": "Multi-location management", "team_permissions": "Team permissions", "audit_logs": "Audit logs", diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index b3e7b949..80c15f96 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -23,6 +23,7 @@ import { Calendar, Clock, Users, + Code2, } from 'lucide-react'; import { SettingsSidebarSection, @@ -40,7 +41,7 @@ interface ParentContext { // Map settings pages to their required plan features const SETTINGS_PAGE_FEATURES: Record = { - '/dashboard/settings/branding': 'white_label', + '/dashboard/settings/branding': 'remove_branding', '/dashboard/settings/custom-domains': 'custom_domain', '/dashboard/settings/api': 'api_access', '/dashboard/settings/authentication': 'custom_oauth', @@ -125,7 +126,7 @@ const SettingsLayout: React.FC = () => { icon={Palette} label={t('settings.appearance.title', 'Appearance')} description={t('settings.appearance.description', 'Logo, colors, theme')} - locked={isLocked('white_label')} + locked={isLocked('remove_branding')} /> { description={t('settings.customDomains.description', 'Use your own domain')} locked={isLocked('custom_domain')} /> + {/* Integrations Section */} diff --git a/frontend/src/layouts/__tests__/SettingsLayout.test.tsx b/frontend/src/layouts/__tests__/SettingsLayout.test.tsx index dcf5e7a0..6d310f01 100644 --- a/frontend/src/layouts/__tests__/SettingsLayout.test.tsx +++ b/frontend/src/layouts/__tests__/SettingsLayout.test.tsx @@ -372,7 +372,7 @@ describe('SettingsLayout', () => { // Reset mock for locked feature tests mockCanUse.mockImplementation((feature: string) => { // Lock specific features - if (feature === 'white_label') return false; + if (feature === 'remove_branding') return false; if (feature === 'custom_domain') return false; if (feature === 'api_access') return false; if (feature === 'custom_oauth') return false; @@ -381,7 +381,7 @@ describe('SettingsLayout', () => { }); }); - it('shows lock icon for Appearance link when white_label is locked', () => { + it('shows lock icon for Appearance link when remove_branding is locked', () => { renderWithRouter(); const appearanceLink = screen.getByRole('link', { name: /Appearance/i }); const lockIcons = within(appearanceLink).queryAllByTestId('lock-icon'); @@ -461,7 +461,7 @@ describe('SettingsLayout', () => { it('passes isFeatureLocked to child routes when feature is locked', () => { mockCanUse.mockImplementation((feature: string) => { - return feature !== 'white_label'; + return feature !== 'remove_branding'; }); const ChildComponent = () => { @@ -485,7 +485,7 @@ describe('SettingsLayout', () => { ); expect(screen.getByTestId('is-locked')).toHaveTextContent('true'); - expect(screen.getByTestId('locked-feature')).toHaveTextContent('white_label'); + expect(screen.getByTestId('locked-feature')).toHaveTextContent('remove_branding'); }); it('passes isFeatureLocked as false when feature is unlocked', () => { diff --git a/frontend/src/pages/Customers.tsx b/frontend/src/pages/Customers.tsx index ab2c5f0c..a2dd390f 100644 --- a/frontend/src/pages/Customers.tsx +++ b/frontend/src/pages/Customers.tsx @@ -1,19 +1,32 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Customer, User } from '../types'; -import { useCustomers, useCreateCustomer } from '../hooks/useCustomers'; +import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer } from '../hooks/useCustomers'; +import { useAppointments } from '../hooks/useAppointments'; +import { useServices } from '../hooks/useServices'; import { Search, Plus, - MoreHorizontal, Filter, ArrowUpDown, Mail, Phone, X, - Eye + Eye, + Pencil, + Calendar, + Clock, + CheckCircle, + XCircle, + AlertCircle, + ChevronRight, + ChevronLeft, + FileText, + StickyNote, + History, + Save } from 'lucide-react'; import Portal from '../components/Portal'; @@ -30,6 +43,15 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => direction: 'asc' }); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); + const [isNotesModalOpen, setIsNotesModalOpen] = useState(false); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [selectedAppointment, setSelectedAppointment] = useState(null); + const [editingCustomer, setEditingCustomer] = useState(null); + const [customerNotes, setCustomerNotes] = useState(''); + const [isEditingNotes, setIsEditingNotes] = useState(false); const [formData, setFormData] = useState({ name: '', email: '', @@ -39,9 +61,71 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => state: '', zip: '' }); + const [editFormData, setEditFormData] = useState({ + name: '', + email: '', + phone: '', + isActive: true + }); - const { data: customers = [], isLoading, error } = useCustomers(); + // Infinite scroll for customers + const { + data: customersData, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useCustomersInfinite(searchTerm ? { search: searchTerm } : undefined); + + const { data: services = [] } = useServices(); const createCustomerMutation = useCreateCustomer(); + const updateCustomerMutation = useUpdateCustomer(); + + // Transform paginated data to flat array + const customers: Customer[] = useMemo(() => { + if (!customersData?.pages) return []; + return customersData.pages.flatMap(page => + (page.results || []).map((c: any) => ({ + id: String(c.id), + name: c.name || '', + email: c.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 || '', + })) + ); + }, [customersData]); + + // Infinite scroll observer + const observerRef = useRef(null); + const loadMoreRef = useCallback((node: HTMLDivElement | null) => { + if (isFetchingNextPage) return; + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasNextPage) { + fetchNextPage(); + } + }); + + if (node) observerRef.current.observe(node); + }, [isFetchingNextPage, hasNextPage, fetchNextPage]); + + // Fetch appointments for selected customer + const { data: customerAppointments = [], isLoading: isLoadingAppointments } = useAppointments( + selectedCustomer ? { customer: selectedCustomer.id } : undefined + ); const handleSort = (key: keyof Customer) => { setSortConfig(current => ({ @@ -58,31 +142,121 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => const handleAddCustomer = (e: React.FormEvent) => { e.preventDefault(); - const newCustomer: Partial = { + createCustomerMutation.mutate({ + name: formData.name, + email: formData.email, phone: formData.phone, - city: formData.city, - state: formData.state, - zip: formData.zip, - status: 'Active', - tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0) - }; - - createCustomerMutation.mutate(newCustomer); + }); setIsAddModalOpen(false); setFormData({ name: '', email: '', phone: '', tags: '', city: '', state: '', zip: '' }); }; - const filteredCustomers = useMemo(() => { - let sorted = [...customers]; + const handleEditClick = (customer: Customer) => { + setEditingCustomer(customer); + setEditFormData({ + name: customer.name, + email: customer.email, + phone: customer.phone || '', + isActive: customer.status === 'Active' + }); + setIsEditModalOpen(true); + }; - if (searchTerm) { - const lowerTerm = searchTerm.toLowerCase(); - sorted = sorted.filter(c => - c.name.toLowerCase().includes(lowerTerm) || - c.email.toLowerCase().includes(lowerTerm) || - c.phone.includes(searchTerm) - ); + const handleEditInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + if (type === 'checkbox') { + setEditFormData(prev => ({ ...prev, [name]: (e.target as HTMLInputElement).checked })); + } else { + setEditFormData(prev => ({ ...prev, [name]: value })); } + }; + + const handleUpdateCustomer = (e: React.FormEvent) => { + e.preventDefault(); + if (!editingCustomer) return; + + updateCustomerMutation.mutate({ + id: editingCustomer.id, + updates: { + name: editFormData.name, + email: editFormData.email, + phone: editFormData.phone, + isActive: editFormData.isActive + } + }); + setIsEditModalOpen(false); + setEditingCustomer(null); + }; + + const handleRowClick = (customer: Customer) => { + setSelectedCustomer(customer); + setIsDetailModalOpen(true); + }; + + const handleViewHistory = () => { + setIsDetailModalOpen(false); + setIsHistoryModalOpen(true); + }; + + const handleViewNotes = () => { + if (selectedCustomer) { + setCustomerNotes(selectedCustomer.notes || ''); + setIsEditingNotes(false); + } + setIsDetailModalOpen(false); + setIsNotesModalOpen(true); + }; + + const handleSaveNotes = () => { + if (!selectedCustomer) return; + + updateCustomerMutation.mutate({ + id: selectedCustomer.id, + updates: { notes: customerNotes } + }, { + onSuccess: () => { + setIsEditingNotes(false); + // Update the selected customer in local state + setSelectedCustomer(prev => prev ? { ...prev, notes: customerNotes } : null); + } + }); + }; + + const getServiceName = (serviceId: string) => { + const service = services.find(s => String(s.id) === serviceId); + return service?.name || t('customers.unknownService'); + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'COMPLETED': + return ; + case 'CANCELED': + case 'NO_SHOW': + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'COMPLETED': + return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; + case 'CANCELED': + return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + case 'NO_SHOW': + return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'; + case 'SCHEDULED': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; + } + }; + + // Sort customers (search is handled server-side for infinite scroll) + const filteredCustomers = useMemo(() => { + const sorted = [...customers]; sorted.sort((a, b) => { const aValue = a[sortConfig.key]; @@ -97,7 +271,7 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => }); return sorted; - }, [customers, searchTerm, sortConfig]); + }, [customers, sortConfig]); // Only owners can masquerade as customers (per backend permissions) const canMasquerade = effectiveUser.role === 'owner'; @@ -180,7 +354,11 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => {filteredCustomers.map((customer: any) => { const customerUser = customer.user_data; return ( - + handleRowClick(customer)} + >
@@ -203,18 +381,22 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => {customer.lastVisit ? customer.lastVisit.toLocaleDateString() : {t('customers.never')}}
+ {canMasquerade && customerUser && ( )} -
@@ -222,7 +404,18 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => })} - {filteredCustomers.length === 0 && (

{t('customers.noCustomersFound')}

)} + {filteredCustomers.length === 0 && !isLoading && (

{t('customers.noCustomersFound')}

)} + + {/* Infinite scroll trigger */} +
+ + {/* Loading more indicator */} + {isFetchingNextPage && ( +
+
+ {t('common.loadingMore')} +
+ )}
@@ -276,6 +469,344 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) =>
)} + + {isEditModalOpen && editingCustomer && ( + +
+
+
+

{t('customers.editCustomer')}

+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +

{t('customers.activeDescription')}

+
+
+ + +
+
+
+
+
+ )} + + {/* Customer Detail Modal - Options to drill down */} + {isDetailModalOpen && selectedCustomer && ( + +
+
+
+
+

{selectedCustomer.name}

+

{selectedCustomer.email}

+
+ +
+
+ + + +
+
+ +
+
+
+
+ )} + + {/* Customer Notes Modal */} + {isNotesModalOpen && selectedCustomer && ( + +
+
+
+
+

{t('customers.customerNotes')}

+

{selectedCustomer.name}

+
+ +
+
+ {isEditingNotes ? ( +
+