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:
@@ -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 = () => {
|
||||
<Routes>
|
||||
<Route path="/" element={<PublicPage />} />
|
||||
<Route path="/book" element={<BookingFlow />} />
|
||||
<Route path="/embed" element={<EmbedBooking />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
@@ -688,6 +693,7 @@ const AppContent: React.FC = () => {
|
||||
{/* Public routes outside BusinessLayout */}
|
||||
<Route path="/" element={<PublicPage />} />
|
||||
<Route path="/book" element={<BookingFlow />} />
|
||||
<Route path="/embed" element={<EmbedBooking />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
|
||||
@@ -953,6 +959,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||
<Route path="embed-widget" element={<EmbedWidgetSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
<Route path="staff-roles" element={<StaffRolesSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
<UpgradePrompt feature="white_label" variant="overlay">
|
||||
<UpgradePrompt feature="remove_branding" variant="overlay">
|
||||
<button data-testid="locked-button">Click Me</button>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
@@ -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(
|
||||
<LockedButton
|
||||
feature="white_label"
|
||||
feature="remove_branding"
|
||||
isLocked={true}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
||||
@@ -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<ChartWidgetProps> = ({
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const isDark = useDarkMode();
|
||||
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||
const formatValue = (value: number) => `${valuePrefix}${value}`;
|
||||
|
||||
return (
|
||||
@@ -58,8 +61,8 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
|
||||
<div className="flex-1 min-h-[200px]">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
@@ -67,13 +70,7 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
<YAxis axisLine={false} tickLine={false} tickFormatter={formatValue} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
contentStyle={tooltipStyles.contentStyle}
|
||||
formatter={(value: number) => [formatValue(value), title]}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
@@ -84,13 +81,7 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
contentStyle={tooltipStyles.contentStyle}
|
||||
formatter={(value: number) => [value, title]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 4, fill: color }} />
|
||||
|
||||
@@ -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<CustomerBreakdownWidgetProps> = ({
|
||||
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<CustomerBreakdownWidgetProps> = ({
|
||||
|
||||
<div className="flex-1 flex items-center gap-3 min-h-0">
|
||||
{/* Pie Chart */}
|
||||
<div className="w-20 h-20 flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={60} minHeight={60}>
|
||||
<div className="flex-shrink-0">
|
||||
<ResponsiveContainer width={80} height={80}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={breakdownData.chartData}
|
||||
@@ -73,15 +76,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyles.contentStyle} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, FeatureKey> = {
|
||||
'/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')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email-templates"
|
||||
@@ -140,6 +141,12 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||
locked={isLocked('custom_domain')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/embed-widget"
|
||||
icon={Code2}
|
||||
label={t('settings.embedWidget.title', 'Embed Widget')}
|
||||
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Integrations Section */}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<CustomersProps> = ({ 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<Customer | null>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<any | null>(null);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
const [customerNotes, setCustomerNotes] = useState('');
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -39,9 +61,71 @@ const Customers: React.FC<CustomersProps> = ({ 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<IntersectionObserver | null>(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<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
const handleAddCustomer = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newCustomer: Partial<Customer> = {
|
||||
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<HTMLInputElement | HTMLSelectElement>) => {
|
||||
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 <CheckCircle size={16} className="text-green-500" />;
|
||||
case 'CANCELED':
|
||||
case 'NO_SHOW':
|
||||
return <XCircle size={16} className="text-red-500" />;
|
||||
default:
|
||||
return <Clock size={16} className="text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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<CustomersProps> = ({ 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<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
{filteredCustomers.map((customer: any) => {
|
||||
const customerUser = customer.user_data;
|
||||
return (
|
||||
<tr key={customer.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
|
||||
<tr
|
||||
key={customer.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer"
|
||||
onClick={() => handleRowClick(customer)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
|
||||
@@ -203,18 +381,22 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }}
|
||||
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} /> {t('common.edit')}
|
||||
</button>
|
||||
{canMasquerade && customerUser && (
|
||||
<button
|
||||
onClick={() => onMasquerade(customerUser)}
|
||||
onClick={(e) => { e.stopPropagation(); onMasquerade(customerUser); }}
|
||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
||||
title={t('common.masqueradeAsUser')}
|
||||
>
|
||||
<Eye size={14} /> {t('common.masquerade')}
|
||||
</button>
|
||||
)}
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -222,7 +404,18 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredCustomers.length === 0 && (<div className="p-12 text-center"><p className="text-gray-500 dark:text-gray-400">{t('customers.noCustomersFound')}</p></div>)}
|
||||
{filteredCustomers.length === 0 && !isLoading && (<div className="p-12 text-center"><p className="text-gray-500 dark:text-gray-400">{t('customers.noCustomersFound')}</p></div>)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<div ref={loadMoreRef} className="h-1" />
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-600"></div>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">{t('common.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +469,344 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{isEditModalOpen && editingCustomer && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xl bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('customers.editCustomer')}</h3>
|
||||
<button onClick={() => { setIsEditModalOpen(false); setEditingCustomer(null); }} className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleUpdateCustomer} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.fullName')} <span className="text-red-500">*</span></label>
|
||||
<input type="text" name="name" required value={editFormData.name} onChange={handleEditInputChange} 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 focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.namePlaceholder')} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.emailAddress')} <span className="text-red-500">*</span></label>
|
||||
<input type="email" name="email" required value={editFormData.email} onChange={handleEditInputChange} 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 focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.emailPlaceholder')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.phoneNumber')}</label>
|
||||
<input type="tel" name="phone" value={editFormData.phone} onChange={handleEditInputChange} 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 focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.phonePlaceholder')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
checked={editFormData.isActive}
|
||||
onChange={handleEditInputChange}
|
||||
className="w-4 h-4 text-brand-600 border-gray-300 rounded focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('customers.active')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('customers.activeDescription')}</p>
|
||||
</div>
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={() => { setIsEditModalOpen(false); setEditingCustomer(null); }} className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={updateCustomerMutation.isPending} className="flex-1 px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{updateCustomerMutation.isPending ? t('common.saving') : t('common.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Customer Detail Modal - Options to drill down */}
|
||||
{isDetailModalOpen && selectedCustomer && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{selectedCustomer.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedCustomer.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsDetailModalOpen(false); setSelectedCustomer(null); }}
|
||||
className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
<button
|
||||
onClick={handleViewHistory}
|
||||
className="w-full flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-500 transition-colors text-left group"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<History size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('customers.appointmentHistory')}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('customers.viewPastAppointments')}</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleViewNotes}
|
||||
className="w-full flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-500 transition-colors text-left group"
|
||||
>
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<StickyNote size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('customers.customerNotes')}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedCustomer.notes ? t('customers.hasCustomerNotes') : t('customers.noCustomerNotes')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<button
|
||||
onClick={() => { setIsDetailModalOpen(false); setSelectedCustomer(null); }}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Customer Notes Modal */}
|
||||
{isNotesModalOpen && selectedCustomer && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xl bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('customers.customerNotes')}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedCustomer.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsNotesModalOpen(false); setSelectedCustomer(null); setIsEditingNotes(false); }}
|
||||
className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{isEditingNotes ? (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={customerNotes}
|
||||
onChange={(e) => setCustomerNotes(e.target.value)}
|
||||
rows={8}
|
||||
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 focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors resize-none"
|
||||
placeholder={t('customers.enterNotesPlaceholder')}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditingNotes(false);
|
||||
setCustomerNotes(selectedCustomer.notes || '');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveNotes}
|
||||
disabled={updateCustomerMutation.isPending}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={16} />
|
||||
{updateCustomerMutation.isPending ? t('common.saving') : t('common.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{customerNotes ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{customerNotes}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-8 text-center">
|
||||
<StickyNote size={32} className="mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('customers.noNotesYet')}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditingNotes(true)}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/30 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
{customerNotes ? t('customers.editNotes') : t('customers.addNotes')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<button
|
||||
onClick={() => { setIsNotesModalOpen(false); setSelectedCustomer(null); setIsEditingNotes(false); }}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{isHistoryModalOpen && selectedCustomer && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl max-h-[80vh] bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('customers.appointmentHistory')}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedCustomer.name}</p>
|
||||
</div>
|
||||
<button onClick={() => { setIsHistoryModalOpen(false); setSelectedCustomer(null); setSelectedAppointment(null); }} className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selectedAppointment ? (
|
||||
/* Appointment Detail View */
|
||||
<div className="space-y-6">
|
||||
<button
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
className="flex items-center gap-1 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t('customers.backToList')}
|
||||
</button>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{getServiceName(selectedAppointment.serviceId)}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium mt-2 ${getStatusColor(selectedAppointment.status)}`}>
|
||||
{selectedAppointment.status}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusIcon(selectedAppointment.status)}
|
||||
</div>
|
||||
|
||||
{/* Date & Time */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">{t('customers.date')}</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Calendar size={14} className="text-gray-400" />
|
||||
{selectedAppointment.startTime.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">{t('customers.time')}</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Clock size={14} className="text-gray-400" />
|
||||
{selectedAppointment.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
<span className="text-gray-500 dark:text-gray-400">({selectedAppointment.durationMinutes} {t('common.minutes')})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-1">
|
||||
<FileText size={12} />
|
||||
{t('customers.notes')}
|
||||
</p>
|
||||
{selectedAppointment.notes ? (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
|
||||
{selectedAppointment.notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 italic">
|
||||
{t('customers.noNotes')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoadingAppointments ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : customerAppointments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar size={48} className="text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('customers.noAppointments')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Appointment List */
|
||||
<div className="space-y-2">
|
||||
{customerAppointments
|
||||
.sort((a, b) => b.startTime.getTime() - a.startTime.getTime())
|
||||
.map((appointment) => (
|
||||
<button
|
||||
key={appointment.id}
|
||||
onClick={() => setSelectedAppointment(appointment)}
|
||||
className="w-full flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-500 transition-colors text-left group"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon(appointment.status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{getServiceName(appointment.serviceId)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(appointment.status)}`}>
|
||||
{appointment.status}
|
||||
</span>
|
||||
{appointment.notes && (
|
||||
<span title={t('customers.hasNotes')}>
|
||||
<FileText size={14} className="text-gray-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
{appointment.startTime.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{appointment.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span>{appointment.durationMinutes} {t('common.minutes')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{t('customers.totalAppointments')}: {customerAppointments.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setIsHistoryModalOpen(false); setSelectedCustomer(null); setSelectedAppointment(null); }}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
819
frontend/src/pages/EmbedBooking.tsx
Normal file
819
frontend/src/pages/EmbedBooking.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* Embeddable Booking Widget Page
|
||||
*
|
||||
* A minimal booking flow designed to be embedded via iframe on external websites.
|
||||
* - No header/footer chrome
|
||||
* - Communicates with parent window via postMessage for height adjustments
|
||||
* - Services requiring deposits redirect to main booking page
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Phone,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePublicServices,
|
||||
usePublicBusinessInfo,
|
||||
usePublicAvailability,
|
||||
usePublicBusinessHours,
|
||||
useCreateBooking,
|
||||
PublicService,
|
||||
} from '../hooks/useBooking';
|
||||
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../utils/dateUtils';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
type BookingStep = 'service' | 'datetime' | 'details' | 'confirm' | 'success';
|
||||
|
||||
interface GuestInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const EmbedBooking: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Config from URL params
|
||||
const showPrices = searchParams.get('prices') !== 'false';
|
||||
const showDuration = searchParams.get('duration') !== 'false';
|
||||
const primaryColor = searchParams.get('color') || '#6366f1';
|
||||
const hideDeposits = searchParams.get('hideDeposits') === 'true';
|
||||
|
||||
// State
|
||||
const [step, setStep] = useState<BookingStep>('service');
|
||||
const [selectedService, setSelectedService] = useState<PublicService | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [selectedTimeSlot, setSelectedTimeSlot] = useState<string | null>(null);
|
||||
const [selectedTimeISO, setSelectedTimeISO] = useState<string | null>(null);
|
||||
const [guestInfo, setGuestInfo] = useState<GuestInfo>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const { data: services, isLoading: servicesLoading } = usePublicServices();
|
||||
const { data: businessInfo } = usePublicBusinessInfo();
|
||||
const createBooking = useCreateBooking();
|
||||
|
||||
// Calendar state
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear());
|
||||
|
||||
// Date range for business hours
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const start = new Date(currentYear, currentMonth, 1);
|
||||
const end = new Date(currentYear, currentMonth + 1, 0);
|
||||
return {
|
||||
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
|
||||
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`,
|
||||
};
|
||||
}, [currentMonth, currentYear]);
|
||||
|
||||
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
|
||||
|
||||
const openDaysMap = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
if (businessHours?.dates) {
|
||||
businessHours.dates.forEach((day) => {
|
||||
map.set(day.date, day.is_open);
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [businessHours]);
|
||||
|
||||
const dateString = selectedDate
|
||||
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
|
||||
: undefined;
|
||||
|
||||
const { data: availability, isLoading: availabilityLoading } = usePublicAvailability(
|
||||
selectedService?.id,
|
||||
dateString
|
||||
);
|
||||
|
||||
// Notify parent window of height changes for responsive iframe
|
||||
useEffect(() => {
|
||||
const sendHeight = () => {
|
||||
const height = document.body.scrollHeight;
|
||||
window.parent.postMessage({ type: 'smoothschedule-embed-height', height }, '*');
|
||||
};
|
||||
|
||||
sendHeight();
|
||||
const observer = new ResizeObserver(sendHeight);
|
||||
observer.observe(document.body);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [step, selectedService, availability]);
|
||||
|
||||
// Filter services - optionally hide those requiring deposits
|
||||
const displayServices = useMemo(() => {
|
||||
if (!services) return [];
|
||||
if (hideDeposits) {
|
||||
return services.filter(s => !s.deposit_amount_cents || s.deposit_amount_cents === 0);
|
||||
}
|
||||
return services;
|
||||
}, [services, hideDeposits]);
|
||||
|
||||
// Check if service requires deposit (payment on main site)
|
||||
const requiresDeposit = (service: PublicService) => {
|
||||
return service.deposit_amount_cents && service.deposit_amount_cents > 0;
|
||||
};
|
||||
|
||||
// Format price from cents
|
||||
const formatPrice = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
|
||||
|
||||
// Calendar helpers
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
|
||||
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
|
||||
const isPast = (day: number) => {
|
||||
const d = new Date(currentYear, currentMonth, day);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return d < now;
|
||||
};
|
||||
|
||||
const isClosed = (day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (openDaysMap.size > 0) {
|
||||
return openDaysMap.get(dateStr) === false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return (
|
||||
selectedDate?.getDate() === day &&
|
||||
selectedDate?.getMonth() === currentMonth &&
|
||||
selectedDate?.getFullYear() === currentYear
|
||||
);
|
||||
};
|
||||
|
||||
// Get the main booking URL for redirects
|
||||
const getMainBookingUrl = () => {
|
||||
return window.location.origin + '/book';
|
||||
};
|
||||
|
||||
// Handlers
|
||||
const handleSelectService = (service: PublicService) => {
|
||||
// If service requires deposit, redirect to main booking page
|
||||
if (requiresDeposit(service)) {
|
||||
window.open(getMainBookingUrl(), '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedService(service);
|
||||
setSelectedDate(null);
|
||||
setSelectedTimeSlot(null);
|
||||
setStep('datetime');
|
||||
};
|
||||
|
||||
const handleSelectDate = (day: number) => {
|
||||
const newDate = new Date(currentYear, currentMonth, day);
|
||||
setSelectedDate(newDate);
|
||||
setSelectedTimeSlot(null);
|
||||
};
|
||||
|
||||
const handleSelectTime = (displayTime: string, isoTime: string) => {
|
||||
setSelectedTimeSlot(displayTime);
|
||||
setSelectedTimeISO(isoTime);
|
||||
};
|
||||
|
||||
const handleContinueToDetails = () => {
|
||||
if (selectedService && selectedDate && selectedTimeSlot) {
|
||||
setStep('details');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueToConfirm = () => {
|
||||
if (guestInfo.firstName && guestInfo.lastName && guestInfo.email) {
|
||||
setStep('confirm');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitBooking = async () => {
|
||||
if (!selectedService || !selectedTimeISO) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createBooking.mutateAsync({
|
||||
service_id: selectedService.id,
|
||||
start_time: selectedTimeISO,
|
||||
guest_first_name: guestInfo.firstName,
|
||||
guest_last_name: guestInfo.lastName,
|
||||
guest_email: guestInfo.email,
|
||||
guest_phone: guestInfo.phone || undefined,
|
||||
notes: guestInfo.notes || undefined,
|
||||
});
|
||||
|
||||
setStep('success');
|
||||
// Notify parent window of successful booking
|
||||
window.parent.postMessage({ type: 'smoothschedule-booking-complete' }, '*');
|
||||
toast.success('Booking confirmed!');
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Failed to create booking');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('service');
|
||||
setSelectedService(null);
|
||||
setSelectedDate(null);
|
||||
setSelectedTimeSlot(null);
|
||||
setSelectedTimeISO(null);
|
||||
setGuestInfo({ firstName: '', lastName: '', email: '', phone: '', notes: '' });
|
||||
};
|
||||
|
||||
// Step indicator
|
||||
const steps = [
|
||||
{ key: 'service', label: 'Service' },
|
||||
{ key: 'datetime', label: 'Date & Time' },
|
||||
{ key: 'details', label: 'Your Info' },
|
||||
{ key: 'confirm', label: 'Confirm' },
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
||||
|
||||
// Dynamic styles based on primary color
|
||||
const buttonStyle = {
|
||||
backgroundColor: primaryColor,
|
||||
};
|
||||
|
||||
// Render functions
|
||||
const renderStepIndicator = () => {
|
||||
if (step === 'success') return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
{steps.map((s, idx) => (
|
||||
<React.Fragment key={s.key}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors ${
|
||||
idx < currentStepIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: idx === currentStepIndex
|
||||
? 'text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
style={idx === currentStepIndex ? buttonStyle : undefined}
|
||||
>
|
||||
{idx < currentStepIndex ? <Check className="w-3 h-3" /> : idx + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-1.5 text-xs hidden sm:inline ${
|
||||
idx === currentStepIndex
|
||||
? 'text-gray-900 font-medium'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-6 sm:w-10 h-0.5 mx-1.5 ${
|
||||
idx < currentStepIndex ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderServiceStep = () => {
|
||||
if (servicesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: primaryColor }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayServices || displayServices.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-gray-400" />
|
||||
<p>No services available at this time.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{displayServices.map((service) => {
|
||||
const needsDeposit = requiresDeposit(service);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={service.id}
|
||||
onClick={() => handleSelectService(service)}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||
selectedService?.id === service.id
|
||||
? 'border-indigo-600 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||
}`}
|
||||
style={selectedService?.id === service.id ? { borderColor: primaryColor, backgroundColor: `${primaryColor}10` } : undefined}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{service.name}</h3>
|
||||
{needsDeposit && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 bg-amber-100 text-amber-700 rounded-full">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Book on site
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{service.description && (
|
||||
<p className="mt-1 text-sm text-gray-500 line-clamp-2">{service.description}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-3 text-sm">
|
||||
{showDuration && (
|
||||
<span className="flex items-center text-gray-600">
|
||||
<Clock className="w-3.5 h-3.5 mr-1" />
|
||||
{service.duration} min
|
||||
</span>
|
||||
)}
|
||||
{needsDeposit && (
|
||||
<span className="text-amber-600 text-xs">
|
||||
Deposit: {formatPrice(service.deposit_amount_cents!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showPrices && (
|
||||
<div className="text-lg font-bold text-gray-900 flex items-center">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{(service.price_cents / 100).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show note if there are deposit-required services hidden */}
|
||||
{hideDeposits && services && services.length > displayServices.length && (
|
||||
<p className="text-xs text-gray-500 text-center pt-2">
|
||||
Some services require payment and are available on{' '}
|
||||
<a
|
||||
href={getMainBookingUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-gray-700"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
our booking page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDateTimeStep = () => {
|
||||
const displayTimezone = availability?.timezone_display_mode === 'viewer'
|
||||
? getUserTimezone()
|
||||
: availability?.business_timezone || getUserTimezone();
|
||||
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => setStep('service')}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back to services
|
||||
</button>
|
||||
|
||||
{/* Selected service summary */}
|
||||
{selectedService && (
|
||||
<div className="p-3 rounded-lg border" style={{ backgroundColor: `${primaryColor}10`, borderColor: `${primaryColor}30` }}>
|
||||
<p className="text-xs font-medium" style={{ color: primaryColor }}>Selected Service</p>
|
||||
<p className="text-gray-900 font-semibold text-sm">{selectedService.name}</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{selectedService.duration} min • {formatPrice(selectedService.price_cents)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Calendar */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 flex items-center text-sm">
|
||||
<CalendarIcon className="w-4 h-4 mr-1.5" style={{ color: primaryColor }} />
|
||||
Select Date
|
||||
</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium w-24 text-center">
|
||||
{monthName} {currentYear}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-1.5 text-center text-xs font-medium text-gray-500">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
|
||||
<div key={d}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{businessHoursLoading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: primaryColor }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const past = isPast(day);
|
||||
const closed = isClosed(day);
|
||||
const disabled = past || closed;
|
||||
const selected = isSelected(day);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
disabled={disabled}
|
||||
onClick={() => handleSelectDate(day)}
|
||||
className={`h-8 w-8 rounded-full flex items-center justify-center text-sm transition-all ${
|
||||
selected
|
||||
? 'text-white'
|
||||
: disabled
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'hover:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
style={selected ? buttonStyle : undefined}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 text-sm">Available Times</h3>
|
||||
{!selectedDate ? (
|
||||
<div className="h-40 flex items-center justify-center text-gray-400 italic text-sm">
|
||||
Please select a date
|
||||
</div>
|
||||
) : availabilityLoading ? (
|
||||
<div className="h-40 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: primaryColor }} />
|
||||
</div>
|
||||
) : availability?.is_open === false ? (
|
||||
<div className="h-40 flex items-center justify-center text-gray-400 text-sm">
|
||||
Business closed on this date
|
||||
</div>
|
||||
) : availability?.slots && availability.slots.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-2">Times shown in {tzAbbrev}</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 max-h-48 overflow-y-auto">
|
||||
{availability.slots.map((slot) => {
|
||||
const displayTime = formatTimeForDisplay(
|
||||
slot.time,
|
||||
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.time}
|
||||
disabled={!slot.available}
|
||||
onClick={() => handleSelectTime(displayTime, slot.time)}
|
||||
className={`py-1.5 px-2 rounded text-xs font-medium transition-all ${
|
||||
!slot.available
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: selectedTimeSlot === displayTime
|
||||
? 'text-white'
|
||||
: 'bg-gray-50 hover:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
style={selectedTimeSlot === displayTime ? buttonStyle : undefined}
|
||||
>
|
||||
{displayTime}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-gray-400 text-sm">
|
||||
No available times
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleContinueToDetails}
|
||||
disabled={!selectedDate || !selectedTimeSlot}
|
||||
className="px-5 py-2 text-white rounded-lg font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-colors flex items-center"
|
||||
style={buttonStyle}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDetailsStep = () => (
|
||||
<div className="space-y-4 max-w-md mx-auto">
|
||||
<button
|
||||
onClick={() => setStep('datetime')}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back to date & time
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Your Information</h3>
|
||||
<p className="text-gray-500 text-sm">Enter your details to complete the booking</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">First Name *</label>
|
||||
<div className="relative">
|
||||
<UserIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={guestInfo.firstName}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, firstName: e.target.value })}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={guestInfo.lastName}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Email *</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={guestInfo.email}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, email: e.target.value })}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Phone (optional)</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
value={guestInfo.phone}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, phone: e.target.value })}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Notes (optional)</label>
|
||||
<textarea
|
||||
value={guestInfo.notes}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, notes: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||
placeholder="Any special requests..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleContinueToConfirm}
|
||||
disabled={!guestInfo.firstName || !guestInfo.lastName || !guestInfo.email}
|
||||
className="px-5 py-2 text-white rounded-lg font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-colors flex items-center"
|
||||
style={buttonStyle}
|
||||
>
|
||||
Review Booking
|
||||
<ArrowRight className="w-4 h-4 ml-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderConfirmStep = () => (
|
||||
<div className="space-y-4 max-w-md mx-auto">
|
||||
<button
|
||||
onClick={() => setStep('details')}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back to your info
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Confirm Your Booking</h3>
|
||||
<p className="text-gray-500 text-sm">Please review the details below</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||
<div className="flex justify-between items-start pb-3 border-b border-gray-200">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Service</p>
|
||||
<p className="font-semibold text-gray-900">{selectedService?.name}</p>
|
||||
<p className="text-xs text-gray-600">{selectedService?.duration} minutes</p>
|
||||
</div>
|
||||
{showPrices && selectedService && (
|
||||
<p className="font-bold text-gray-900">{formatPrice(selectedService.price_cents)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pb-3 border-b border-gray-200">
|
||||
<p className="text-xs text-gray-500">Date & Time</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{selectedDate?.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm">{selectedTimeSlot}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Contact Information</p>
|
||||
<p className="font-semibold text-gray-900">{guestInfo.firstName} {guestInfo.lastName}</p>
|
||||
<p className="text-gray-600 text-sm">{guestInfo.email}</p>
|
||||
{guestInfo.phone && <p className="text-gray-600 text-sm">{guestInfo.phone}</p>}
|
||||
</div>
|
||||
|
||||
{guestInfo.notes && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500">Notes</p>
|
||||
<p className="text-gray-700 text-sm">{guestInfo.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitBooking}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 text-white rounded-lg font-semibold text-sm disabled:opacity-50 hover:opacity-90 transition-colors flex items-center justify-center"
|
||||
style={buttonStyle}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Booking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Confirm Booking
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSuccessStep = () => (
|
||||
<div className="text-center py-8 max-w-md mx-auto">
|
||||
<div className="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Booking Confirmed!</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
A confirmation email has been sent to {guestInfo.email}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-left">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium text-gray-900">{selectedService?.name}</span>
|
||||
<br />
|
||||
{selectedDate?.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}{' '}
|
||||
at {selectedTimeSlot}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-5 py-2 text-white rounded-lg font-medium text-sm hover:opacity-90 transition-colors"
|
||||
style={buttonStyle}
|
||||
>
|
||||
Book Another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<Toaster position="top-center" />
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Business name header */}
|
||||
{businessInfo?.name && (
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-lg font-bold text-gray-900">{businessInfo.name}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step indicator */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Step content */}
|
||||
{step === 'service' && renderServiceStep()}
|
||||
{step === 'datetime' && renderDateTimeStep()}
|
||||
{step === 'details' && renderDetailsStep()}
|
||||
{step === 'confirm' && renderConfirmStep()}
|
||||
{step === 'success' && renderSuccessStep()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmbedBooking;
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { useDarkMode, getChartTooltipStyles } from '../hooks/useDarkMode';
|
||||
|
||||
interface StaffDashboardProps {
|
||||
user: UserType;
|
||||
@@ -59,6 +60,8 @@ interface Appointment {
|
||||
|
||||
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useDarkMode();
|
||||
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
const userResourceName = user.linked_resource_name ?? null;
|
||||
|
||||
@@ -506,7 +509,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={150}>
|
||||
<ResponsiveContainer width="100%" height={256}>
|
||||
<BarChart data={weeklyChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis
|
||||
@@ -523,13 +526,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
contentStyle={tooltipStyles.contentStyle}
|
||||
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
||||
/>
|
||||
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PLATFORM_METRICS } from '../../mockData';
|
||||
import { TrendingUp, TrendingDown, Minus, Users, DollarSign, Activity, AlertCircle } from 'lucide-react';
|
||||
import { TrendingUp, TrendingDown, Users, DollarSign, Activity, AlertCircle } from 'lucide-react';
|
||||
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';
|
||||
import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode';
|
||||
|
||||
const data = [
|
||||
{ name: 'Jan', mrr: 340000 },
|
||||
@@ -17,6 +17,8 @@ const data = [
|
||||
|
||||
const PlatformDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useDarkMode();
|
||||
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||
const getColorClass = (color: string) => {
|
||||
switch(color) {
|
||||
case 'blue': return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400';
|
||||
@@ -63,8 +65,8 @@ const PlatformDashboard: React.FC = () => {
|
||||
{/* MRR Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">{t('platform.mrrGrowth')}</h3>
|
||||
<div className="h-80 min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={200}>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="colorMrr" x1="0" y1="0" x2="0" y2="1">
|
||||
@@ -75,13 +77,8 @@ const PlatformDashboard: React.FC = () => {
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.1} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
|
||||
<YAxis axisLine={false} tickLine={false} tickFormatter={(val) => `$${val/1000}k`} tick={{ fill: '#9CA3AF' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff'
|
||||
}}
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyles.contentStyle}
|
||||
formatter={(val: number) => [`$${val.toLocaleString()}`, 'MRR']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="mrr" stroke="#6366f1" fillOpacity={1} fill="url(#colorMrr)" strokeWidth={3} />
|
||||
|
||||
@@ -186,10 +186,10 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
return {
|
||||
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
|
||||
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
|
||||
can_manage_oauth_credentials: getBooleanFeature(features, 'white_label') && getBooleanFeature(features, 'api_access'),
|
||||
can_manage_oauth_credentials: getBooleanFeature(features, 'remove_branding') && getBooleanFeature(features, 'api_access'),
|
||||
can_accept_payments: getBooleanFeature(features, 'payment_processing'),
|
||||
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
|
||||
can_white_label: getBooleanFeature(features, 'white_label') || getBooleanFeature(features, 'remove_branding'),
|
||||
can_white_label: getBooleanFeature(features, 'remove_branding'),
|
||||
can_api_access: getBooleanFeature(features, 'api_access'),
|
||||
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
|
||||
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
|
||||
@@ -589,7 +589,7 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, can_white_label: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Remove Branding</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
349
frontend/src/pages/settings/EmbedWidgetSettings.tsx
Normal file
349
frontend/src/pages/settings/EmbedWidgetSettings.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Embed Widget Settings Page
|
||||
*
|
||||
* Generate embeddable booking widget code for third-party websites.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Code2,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Settings2,
|
||||
Palette,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
const EmbedWidgetSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
}>();
|
||||
|
||||
// Configuration state
|
||||
const [showPrices, setShowPrices] = useState(true);
|
||||
const [showDuration, setShowDuration] = useState(true);
|
||||
const [hideDeposits, setHideDeposits] = useState(false);
|
||||
const [primaryColor, setPrimaryColor] = useState('#6366f1');
|
||||
const [width, setWidth] = useState('100%');
|
||||
const [height, setHeight] = useState('600');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
// Build the embed URL
|
||||
const embedUrl = useMemo(() => {
|
||||
const baseUrl = `https://${business.subdomain}.smoothschedule.com/embed`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!showPrices) params.set('prices', 'false');
|
||||
if (!showDuration) params.set('duration', 'false');
|
||||
if (hideDeposits) params.set('hideDeposits', 'true');
|
||||
if (primaryColor !== '#6366f1') params.set('color', primaryColor.replace('#', ''));
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
}, [business.subdomain, showPrices, showDuration, hideDeposits, primaryColor]);
|
||||
|
||||
// Generate the embed code
|
||||
const embedCode = useMemo(() => {
|
||||
return `<!-- SmoothSchedule Booking Widget -->
|
||||
<iframe
|
||||
src="${embedUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
frameborder="0"
|
||||
style="border: none; border-radius: 8px;"
|
||||
allow="payment"
|
||||
title="Book an appointment with ${business.name}"
|
||||
></iframe>
|
||||
<script>
|
||||
// Optional: Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.data.type === 'smoothschedule-embed-height') {
|
||||
var iframe = document.querySelector('iframe[src*="${business.subdomain}.smoothschedule.com/embed"]');
|
||||
if (iframe) iframe.style.height = e.data.height + 'px';
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
}, [embedUrl, width, height, business.name, business.subdomain]);
|
||||
|
||||
// Simple embed code (without auto-resize)
|
||||
const simpleEmbedCode = useMemo(() => {
|
||||
return `<iframe src="${embedUrl}" width="${width}" height="${height}" frameborder="0" style="border: none; border-radius: 8px;"></iframe>`;
|
||||
}, [embedUrl, width, height]);
|
||||
|
||||
const handleCopy = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Code2 className="text-brand-500" />
|
||||
{t('settings.embedWidget.title', 'Embed Widget')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.embedWidget.description', 'Add a booking widget to your website or any third-party site')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Notice */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('settings.embedWidget.paymentNotice', 'Payment Handling')}
|
||||
</h4>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t('settings.embedWidget.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.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Configuration */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Settings2 size={20} className="text-brand-500" />
|
||||
{t('settings.embedWidget.configuration', 'Configuration')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Show Prices */}
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('settings.embedWidget.showPrices', 'Show service prices')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPrices}
|
||||
onChange={(e) => setShowPrices(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Show Duration */}
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('settings.embedWidget.showDuration', 'Show service duration')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDuration}
|
||||
onChange={(e) => setShowDuration(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Hide Deposits */}
|
||||
<label className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('settings.embedWidget.hideDeposits', 'Hide services requiring deposits')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.embedWidget.hideDepositsHint', 'Only show services that can be booked without payment')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDeposits}
|
||||
onChange={(e) => setHideDeposits(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
|
||||
{/* Primary Color */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Palette size={16} />
|
||||
{t('settings.embedWidget.primaryColor', 'Primary color')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded cursor-pointer border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm font-mono"
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.embedWidget.width', 'Width')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
||||
placeholder="100%"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.embedWidget.height', 'Height (px)')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
||||
placeholder="600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Preview */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<ExternalLink size={20} className="text-brand-500" />
|
||||
{t('settings.embedWidget.preview', 'Preview')}
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
|
||||
<iframe
|
||||
src={embedUrl.replace('https://', `${window.location.protocol}//`).replace('.smoothschedule.com', `.${window.location.host.split('.').slice(-2).join('.')}`)}
|
||||
width="100%"
|
||||
height="400"
|
||||
frameBorder="0"
|
||||
style={{ border: 'none', borderRadius: '8px' }}
|
||||
title="Booking widget preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<a
|
||||
href={embedUrl.replace('https://', `${window.location.protocol}//`).replace('.smoothschedule.com', `.${window.location.host.split('.').slice(-2).join('.')}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('settings.embedWidget.openInNewTab', 'Open in new tab')}
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Embed Code */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Code2 size={20} className="text-brand-500" />
|
||||
{t('settings.embedWidget.embedCode', 'Embed Code')}
|
||||
</h3>
|
||||
|
||||
{/* Simple Code */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('settings.embedWidget.simpleCode', 'Simple (iframe only)')}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => handleCopy(simpleEmbedCode)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700"
|
||||
>
|
||||
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||
{copied ? t('settings.embedWidget.copied', 'Copied!') : t('settings.embedWidget.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono">
|
||||
{simpleEmbedCode}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Code with auto-resize */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('settings.embedWidget.fullCode', 'With auto-resize')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Info size={12} />
|
||||
{t('settings.embedWidget.recommended', 'Recommended')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(embedCode)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700"
|
||||
>
|
||||
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||
{copied ? t('settings.embedWidget.copied', 'Copied!') : t('settings.embedWidget.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono whitespace-pre-wrap">
|
||||
{embedCode}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Instructions */}
|
||||
<section className="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('settings.embedWidget.howToUse', 'How to Use')}
|
||||
</h3>
|
||||
<ol className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">1</span>
|
||||
<span>{t('settings.embedWidget.step1', 'Configure the widget options above to match your website\'s style.')}</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">2</span>
|
||||
<span>{t('settings.embedWidget.step2', 'Copy the embed code and paste it into your website\'s HTML where you want the booking widget to appear.')}</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">3</span>
|
||||
<span>{t('settings.embedWidget.step3', 'For platforms like WordPress, Squarespace, or Wix, look for an "HTML" or "Embed" block and paste the code there.')}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmbedWidgetSettings;
|
||||
@@ -36,7 +36,7 @@ export interface PlanPermissions {
|
||||
webhooks: boolean;
|
||||
api_access: boolean;
|
||||
custom_domain: boolean;
|
||||
white_label: boolean;
|
||||
remove_branding: boolean;
|
||||
custom_oauth: boolean;
|
||||
automations: boolean;
|
||||
can_create_automations: boolean;
|
||||
@@ -271,6 +271,7 @@ export interface Customer {
|
||||
tags?: string[];
|
||||
userId?: string;
|
||||
paymentMethods: PaymentMethod[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
|
||||
Reference in New Issue
Block a user