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 QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||||
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
||||||
const StaffRolesSettings = React.lazy(() => import('./pages/settings/StaffRolesSettings'));
|
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
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||||
|
|
||||||
@@ -358,6 +362,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PublicPage />} />
|
<Route path="/" element={<PublicPage />} />
|
||||||
<Route path="/book" element={<BookingFlow />} />
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
|
<Route path="/embed" element={<EmbedBooking />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
@@ -688,6 +693,7 @@ const AppContent: React.FC = () => {
|
|||||||
{/* Public routes outside BusinessLayout */}
|
{/* Public routes outside BusinessLayout */}
|
||||||
<Route path="/" element={<PublicPage />} />
|
<Route path="/" element={<PublicPage />} />
|
||||||
<Route path="/book" element={<BookingFlow />} />
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
|
<Route path="/embed" element={<EmbedBooking />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
|
|
||||||
@@ -953,6 +959,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||||
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
||||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||||
|
<Route path="embed-widget" element={<EmbedWidgetSettings />} />
|
||||||
<Route path="api" element={<ApiSettings />} />
|
<Route path="api" element={<ApiSettings />} />
|
||||||
<Route path="staff-roles" element={<StaffRolesSettings />} />
|
<Route path="staff-roles" element={<StaffRolesSettings />} />
|
||||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||||
|
|||||||
@@ -224,9 +224,9 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
category: 'branding',
|
category: 'branding',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'white_label',
|
code: 'remove_branding',
|
||||||
name: 'White Label',
|
name: 'Remove Branding',
|
||||||
description: 'Remove SmoothSchedule branding completely',
|
description: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'branding',
|
category: 'branding',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('UpgradePrompt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render for any feature in inline mode', () => {
|
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) => {
|
features.forEach((feature) => {
|
||||||
const { unmount } = renderWithRouter(
|
const { unmount } = renderWithRouter(
|
||||||
@@ -140,7 +140,7 @@ describe('UpgradePrompt', () => {
|
|||||||
'webhooks',
|
'webhooks',
|
||||||
'api_access',
|
'api_access',
|
||||||
'custom_domain',
|
'custom_domain',
|
||||||
'white_label',
|
'remove_branding',
|
||||||
'plugins',
|
'plugins',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ describe('UpgradePrompt', () => {
|
|||||||
|
|
||||||
it('should make children non-interactive', () => {
|
it('should make children non-interactive', () => {
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
<UpgradePrompt feature="white_label" variant="overlay">
|
<UpgradePrompt feature="remove_branding" variant="overlay">
|
||||||
<button data-testid="locked-button">Click Me</button>
|
<button data-testid="locked-button">Click Me</button>
|
||||||
</UpgradePrompt>
|
</UpgradePrompt>
|
||||||
);
|
);
|
||||||
@@ -374,7 +374,7 @@ describe('LockedSection', () => {
|
|||||||
describe('Different Features', () => {
|
describe('Different Features', () => {
|
||||||
it('should work with different feature keys', () => {
|
it('should work with different feature keys', () => {
|
||||||
const features: FeatureKey[] = [
|
const features: FeatureKey[] = [
|
||||||
'white_label',
|
'remove_branding',
|
||||||
'custom_oauth',
|
'custom_oauth',
|
||||||
'can_create_plugins',
|
'can_create_plugins',
|
||||||
'tasks',
|
'tasks',
|
||||||
@@ -470,7 +470,7 @@ describe('LockedButton', () => {
|
|||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
<LockedButton
|
<LockedButton
|
||||||
feature="white_label"
|
feature="remove_branding"
|
||||||
isLocked={true}
|
isLocked={true}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Line,
|
Line,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { GripVertical, X } from 'lucide-react';
|
import { GripVertical, X } from 'lucide-react';
|
||||||
|
import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode';
|
||||||
|
|
||||||
interface ChartData {
|
interface ChartData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +37,8 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
|||||||
isEditing,
|
isEditing,
|
||||||
onRemove,
|
onRemove,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||||
const formatValue = (value: number) => `${valuePrefix}${value}`;
|
const formatValue = (value: number) => `${valuePrefix}${value}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,8 +61,8 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-[200px]">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
{type === 'bar' ? (
|
{type === 'bar' ? (
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
<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 }} />
|
<YAxis axisLine={false} tickLine={false} tickFormatter={formatValue} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||||
contentStyle={{
|
contentStyle={tooltipStyles.contentStyle}
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
||||||
backgroundColor: '#1F2937',
|
|
||||||
color: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [formatValue(value), title]}
|
formatter={(value: number) => [formatValue(value), title]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
<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 }} />
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={tooltipStyles.contentStyle}
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
||||||
backgroundColor: '#1F2937',
|
|
||||||
color: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value, title]}
|
formatter={(value: number) => [value, title]}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 4, fill: color }} />
|
<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 { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
import { Customer } from '../../types';
|
import { Customer } from '../../types';
|
||||||
|
import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode';
|
||||||
|
|
||||||
interface CustomerBreakdownWidgetProps {
|
interface CustomerBreakdownWidgetProps {
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
@@ -16,6 +17,8 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||||
const breakdownData = useMemo(() => {
|
const breakdownData = useMemo(() => {
|
||||||
// Customers with lastVisit are returning, without are new
|
// Customers with lastVisit are returning, without are new
|
||||||
const returning = customers.filter((c) => c.lastVisit !== null).length;
|
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">
|
<div className="flex-1 flex items-center gap-3 min-h-0">
|
||||||
{/* Pie Chart */}
|
{/* Pie Chart */}
|
||||||
<div className="w-20 h-20 flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={60} minHeight={60}>
|
<ResponsiveContainer width={80} height={80}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={breakdownData.chartData}
|
data={breakdownData.chartData}
|
||||||
@@ -73,15 +76,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
|||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip contentStyle={tooltipStyles.contentStyle} />
|
||||||
contentStyle={{
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
||||||
backgroundColor: '#1F2937',
|
|
||||||
color: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ const FEATURE_CATEGORIES = [
|
|||||||
features: [
|
features: [
|
||||||
{ code: 'custom_domain', label: 'Custom domain' },
|
{ code: 'custom_domain', label: 'Custom domain' },
|
||||||
{ code: 'custom_branding', label: 'Custom branding' },
|
{ code: 'custom_branding', label: 'Custom branding' },
|
||||||
{ code: 'remove_branding', label: 'Remove "Powered by"' },
|
{ code: 'remove_branding', label: 'Remove branding' },
|
||||||
{ code: 'white_label', label: 'White label' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
category: 'customization',
|
category: 'customization',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'white_label',
|
key: 'remove_branding',
|
||||||
planKey: 'can_white_label',
|
planKey: 'can_white_label',
|
||||||
businessKey: 'can_white_label',
|
businessKey: 'can_white_label',
|
||||||
label: 'White Labelling',
|
label: 'Remove Branding',
|
||||||
description: 'Remove SmoothSchedule branding',
|
description: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||||
category: 'customization',
|
category: 'customization',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: true,
|
webhooks: true,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -160,7 +160,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -229,7 +229,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: true,
|
webhooks: true,
|
||||||
api_access: true,
|
api_access: true,
|
||||||
custom_domain: true,
|
custom_domain: true,
|
||||||
white_label: true,
|
remove_branding: true,
|
||||||
custom_oauth: true,
|
custom_oauth: true,
|
||||||
automations: true,
|
automations: true,
|
||||||
can_create_automations: true,
|
can_create_automations: true,
|
||||||
@@ -258,7 +258,7 @@ describe('usePlanFeatures', () => {
|
|||||||
expect(result.current.canUse('webhooks')).toBe(true);
|
expect(result.current.canUse('webhooks')).toBe(true);
|
||||||
expect(result.current.canUse('api_access')).toBe(true);
|
expect(result.current.canUse('api_access')).toBe(true);
|
||||||
expect(result.current.canUse('custom_domain')).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('custom_oauth')).toBe(true);
|
||||||
expect(result.current.canUse('automations')).toBe(true);
|
expect(result.current.canUse('automations')).toBe(true);
|
||||||
expect(result.current.canUse('tasks')).toBe(true);
|
expect(result.current.canUse('tasks')).toBe(true);
|
||||||
@@ -286,7 +286,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -326,7 +326,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -365,7 +365,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: true,
|
webhooks: true,
|
||||||
api_access: true,
|
api_access: true,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -404,7 +404,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: true,
|
webhooks: true,
|
||||||
api_access: true,
|
api_access: true,
|
||||||
custom_domain: true,
|
custom_domain: true,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -446,7 +446,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: true,
|
webhooks: true,
|
||||||
api_access: true,
|
api_access: true,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -486,7 +486,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: true,
|
api_access: true,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -526,7 +526,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -565,7 +565,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -606,7 +606,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -649,7 +649,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -749,7 +749,7 @@ describe('usePlanFeatures', () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
@@ -779,7 +779,7 @@ describe('FEATURE_NAMES', () => {
|
|||||||
'webhooks',
|
'webhooks',
|
||||||
'api_access',
|
'api_access',
|
||||||
'custom_domain',
|
'custom_domain',
|
||||||
'white_label',
|
'remove_branding',
|
||||||
'custom_oauth',
|
'custom_oauth',
|
||||||
'automations',
|
'automations',
|
||||||
'can_create_automations',
|
'can_create_automations',
|
||||||
@@ -805,7 +805,7 @@ describe('FEATURE_NAMES', () => {
|
|||||||
expect(FEATURE_NAMES.webhooks).toBe('Webhooks');
|
expect(FEATURE_NAMES.webhooks).toBe('Webhooks');
|
||||||
expect(FEATURE_NAMES.api_access).toBe('API Access');
|
expect(FEATURE_NAMES.api_access).toBe('API Access');
|
||||||
expect(FEATURE_NAMES.custom_domain).toBe('Custom Domain');
|
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.custom_oauth).toBe('Custom OAuth');
|
||||||
expect(FEATURE_NAMES.automations).toBe('Automations');
|
expect(FEATURE_NAMES.automations).toBe('Automations');
|
||||||
expect(FEATURE_NAMES.can_create_automations).toBe('Custom Automation Creation');
|
expect(FEATURE_NAMES.can_create_automations).toBe('Custom Automation Creation');
|
||||||
@@ -827,7 +827,7 @@ describe('FEATURE_DESCRIPTIONS', () => {
|
|||||||
'webhooks',
|
'webhooks',
|
||||||
'api_access',
|
'api_access',
|
||||||
'custom_domain',
|
'custom_domain',
|
||||||
'white_label',
|
'remove_branding',
|
||||||
'custom_oauth',
|
'custom_oauth',
|
||||||
'automations',
|
'automations',
|
||||||
'can_create_automations',
|
'can_create_automations',
|
||||||
@@ -853,7 +853,7 @@ describe('FEATURE_DESCRIPTIONS', () => {
|
|||||||
expect(FEATURE_DESCRIPTIONS.webhooks).toContain('webhooks');
|
expect(FEATURE_DESCRIPTIONS.webhooks).toContain('webhooks');
|
||||||
expect(FEATURE_DESCRIPTIONS.api_access).toContain('API');
|
expect(FEATURE_DESCRIPTIONS.api_access).toContain('API');
|
||||||
expect(FEATURE_DESCRIPTIONS.custom_domain).toContain('custom domain');
|
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.custom_oauth).toContain('OAuth');
|
||||||
expect(FEATURE_DESCRIPTIONS.automations).toContain('Automate');
|
expect(FEATURE_DESCRIPTIONS.automations).toContain('Automate');
|
||||||
expect(FEATURE_DESCRIPTIONS.can_create_automations).toContain('automations');
|
expect(FEATURE_DESCRIPTIONS.can_create_automations).toContain('automations');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { format } from 'date-fns';
|
|||||||
|
|
||||||
interface AppointmentFilters {
|
interface AppointmentFilters {
|
||||||
resource?: string;
|
resource?: string;
|
||||||
|
customer?: string;
|
||||||
status?: AppointmentStatus;
|
status?: AppointmentStatus;
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
@@ -23,6 +24,7 @@ export const useAppointments = (filters?: AppointmentFilters) => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.resource) params.append('resource', filters.resource);
|
if (filters?.resource) params.append('resource', filters.resource);
|
||||||
|
if (filters?.customer) params.append('customer', filters.customer);
|
||||||
if (filters?.status) params.append('status', filters.status);
|
if (filters?.status) params.append('status', filters.status);
|
||||||
// Send full ISO datetime strings to avoid timezone issues
|
// Send full ISO datetime strings to avoid timezone issues
|
||||||
// The backend will compare datetime fields properly
|
// The backend will compare datetime fields properly
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
|
|||||||
// Platform
|
// Platform
|
||||||
can_api_access: 'api_access',
|
can_api_access: 'api_access',
|
||||||
can_use_custom_domain: 'custom_domain',
|
can_use_custom_domain: 'custom_domain',
|
||||||
can_white_label: 'white_label',
|
can_white_label: 'remove_branding',
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
can_accept_payments: 'payment_processing',
|
can_accept_payments: 'payment_processing',
|
||||||
@@ -328,11 +328,8 @@ export function planFeaturesToLegacyPermissions(
|
|||||||
case 'custom_domain':
|
case 'custom_domain':
|
||||||
permissions.can_use_custom_domain = value as boolean;
|
permissions.can_use_custom_domain = value as boolean;
|
||||||
break;
|
break;
|
||||||
case 'white_label':
|
|
||||||
permissions.can_white_label = value as boolean;
|
|
||||||
break;
|
|
||||||
case 'remove_branding':
|
case 'remove_branding':
|
||||||
permissions.can_white_label = permissions.can_white_label || (value as boolean);
|
permissions.can_white_label = value as boolean;
|
||||||
break;
|
break;
|
||||||
case 'payment_processing':
|
case 'payment_processing':
|
||||||
permissions.can_accept_payments = value as boolean;
|
permissions.can_accept_payments = value as boolean;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const useCurrentBusiness = () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
white_label: false,
|
remove_branding: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
automations: false,
|
automations: false,
|
||||||
can_create_automations: false,
|
can_create_automations: false,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Customer Management Hooks
|
* 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 apiClient from '../api/client';
|
||||||
import { Customer } from '../types';
|
import { Customer } from '../types';
|
||||||
|
|
||||||
@@ -11,8 +11,77 @@ interface CustomerFilters {
|
|||||||
search?: string;
|
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) => {
|
export const useCustomers = (filters?: CustomerFilters) => {
|
||||||
return useQuery<Customer[]>({
|
return useQuery<Customer[]>({
|
||||||
@@ -24,29 +93,25 @@ export const useCustomers = (filters?: CustomerFilters) => {
|
|||||||
|
|
||||||
const { data } = await apiClient.get(`/customers/?${params}`);
|
const { data } = await apiClient.get(`/customers/?${params}`);
|
||||||
|
|
||||||
// Transform backend format to frontend format
|
// Handle paginated response
|
||||||
return data.map((c: any) => ({
|
const results = data.results || data;
|
||||||
id: String(c.id),
|
return results.map(transformCustomer);
|
||||||
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
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
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
|
* Hook to create a customer
|
||||||
*/
|
*/
|
||||||
@@ -54,16 +119,23 @@ export const useCreateCustomer = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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 = {
|
const backendData = {
|
||||||
user: customerData.userId ? parseInt(customerData.userId) : undefined,
|
first_name: firstName,
|
||||||
phone: customerData.phone,
|
last_name: lastName,
|
||||||
city: customerData.city,
|
email: customerData.email,
|
||||||
state: customerData.state,
|
phone: customerData.phone || '',
|
||||||
zip: customerData.zip,
|
// Note: city, state, zip are TODO in backend - not stored yet
|
||||||
status: customerData.status,
|
|
||||||
avatar_url: customerData.avatarUrl,
|
|
||||||
tags: customerData.tags,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = await apiClient.post('/customers/', backendData);
|
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
|
* Hook to update a customer
|
||||||
*/
|
*/
|
||||||
@@ -82,16 +164,25 @@ export const useUpdateCustomer = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Customer> }) => {
|
mutationFn: async ({ id, updates }: { id: string; updates: UpdateCustomerData }) => {
|
||||||
const backendData = {
|
// Parse name into first_name and last_name if provided as single field
|
||||||
phone: updates.phone,
|
let firstName = updates.firstName;
|
||||||
city: updates.city,
|
let lastName = updates.lastName;
|
||||||
state: updates.state,
|
|
||||||
zip: updates.zip,
|
if (updates.name && !firstName && !lastName) {
|
||||||
status: updates.status,
|
const nameParts = updates.name.trim().split(/\s+/);
|
||||||
avatar_url: updates.avatarUrl,
|
firstName = nameParts[0] || '';
|
||||||
tags: updates.tags,
|
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);
|
const { data } = await apiClient.patch(`/customers/${id}/`, backendData);
|
||||||
return data;
|
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)
|
// Boolean features (permissions)
|
||||||
CAN_ACCEPT_PAYMENTS: 'can_accept_payments',
|
CAN_ACCEPT_PAYMENTS: 'can_accept_payments',
|
||||||
CAN_USE_CUSTOM_DOMAIN: 'can_use_custom_domain',
|
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_API_ACCESS: 'can_api_access',
|
||||||
CAN_USE_SMS_REMINDERS: 'can_use_sms_reminders',
|
CAN_USE_SMS_REMINDERS: 'can_use_sms_reminders',
|
||||||
CAN_USE_MASKED_PHONE_NUMBERS: 'can_use_masked_phone_numbers',
|
CAN_USE_MASKED_PHONE_NUMBERS: 'can_use_masked_phone_numbers',
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
api_access: 'API Access',
|
api_access: 'API Access',
|
||||||
custom_domain: 'Custom Domain',
|
custom_domain: 'Custom Domain',
|
||||||
white_label: 'White Label',
|
remove_branding: 'Remove Branding',
|
||||||
custom_oauth: 'Custom OAuth',
|
custom_oauth: 'Custom OAuth',
|
||||||
automations: 'Automations',
|
automations: 'Automations',
|
||||||
can_create_automations: 'Custom Automation Creation',
|
can_create_automations: 'Custom Automation Creation',
|
||||||
@@ -104,7 +104,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Integrate with external services using webhooks',
|
webhooks: 'Integrate with external services using webhooks',
|
||||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||||
custom_domain: 'Use your own custom domain for your booking site',
|
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',
|
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||||
automations: 'Automate repetitive tasks with custom workflows',
|
automations: 'Automate repetitive tasks with custom workflows',
|
||||||
can_create_automations: 'Create custom automations tailored to your business needs',
|
can_create_automations: 'Create custom automations tailored to your business needs',
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"masquerade": "Masquerade",
|
"masquerade": "Masquerade",
|
||||||
"masqueradeAsUser": "Masquerade as User",
|
"masqueradeAsUser": "Masquerade as User",
|
||||||
"plan": "Plan"
|
"plan": "Plan",
|
||||||
|
"loadingMore": "Loading more...",
|
||||||
|
"minutes": "min"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
@@ -942,6 +944,7 @@
|
|||||||
"contactInfo": "Contact Info",
|
"contactInfo": "Contact Info",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"activeDescription": "Inactive customers cannot log in or book appointments.",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"never": "Never",
|
"never": "Never",
|
||||||
"customer": "Customer",
|
"customer": "Customer",
|
||||||
@@ -953,6 +956,22 @@
|
|||||||
"errorLoading": "Error loading customers",
|
"errorLoading": "Error loading customers",
|
||||||
"deleteCustomer": "Delete Customer",
|
"deleteCustomer": "Delete Customer",
|
||||||
"deleteConfirmation": "Are you sure you want to delete this customer? This action cannot be undone.",
|
"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",
|
"password": "Password",
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"passwordPlaceholder": "Leave blank to keep current password",
|
"passwordPlaceholder": "Leave blank to keep current password",
|
||||||
@@ -1354,6 +1373,34 @@
|
|||||||
"title": "Custom Domains",
|
"title": "Custom Domains",
|
||||||
"description": "Use your own domain"
|
"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": {
|
"api": {
|
||||||
"title": "API & Webhooks",
|
"title": "API & Webhooks",
|
||||||
"description": "API tokens, webhooks"
|
"description": "API tokens, webhooks"
|
||||||
@@ -2021,8 +2068,7 @@
|
|||||||
"max_api_calls_per_day": "API calls/day",
|
"max_api_calls_per_day": "API calls/day",
|
||||||
"custom_domain": "Custom domain",
|
"custom_domain": "Custom domain",
|
||||||
"custom_branding": "Custom branding",
|
"custom_branding": "Custom branding",
|
||||||
"remove_branding": "Remove \"Powered by\"",
|
"remove_branding": "Remove branding",
|
||||||
"white_label": "White label",
|
|
||||||
"multi_location": "Multi-location management",
|
"multi_location": "Multi-location management",
|
||||||
"team_permissions": "Team permissions",
|
"team_permissions": "Team permissions",
|
||||||
"audit_logs": "Audit logs",
|
"audit_logs": "Audit logs",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
|
Code2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SettingsSidebarSection,
|
SettingsSidebarSection,
|
||||||
@@ -40,7 +41,7 @@ interface ParentContext {
|
|||||||
|
|
||||||
// Map settings pages to their required plan features
|
// Map settings pages to their required plan features
|
||||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
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/custom-domains': 'custom_domain',
|
||||||
'/dashboard/settings/api': 'api_access',
|
'/dashboard/settings/api': 'api_access',
|
||||||
'/dashboard/settings/authentication': 'custom_oauth',
|
'/dashboard/settings/authentication': 'custom_oauth',
|
||||||
@@ -125,7 +126,7 @@ const SettingsLayout: React.FC = () => {
|
|||||||
icon={Palette}
|
icon={Palette}
|
||||||
label={t('settings.appearance.title', 'Appearance')}
|
label={t('settings.appearance.title', 'Appearance')}
|
||||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||||
locked={isLocked('white_label')}
|
locked={isLocked('remove_branding')}
|
||||||
/>
|
/>
|
||||||
<SettingsSidebarItem
|
<SettingsSidebarItem
|
||||||
to="/dashboard/settings/email-templates"
|
to="/dashboard/settings/email-templates"
|
||||||
@@ -140,6 +141,12 @@ const SettingsLayout: React.FC = () => {
|
|||||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||||
locked={isLocked('custom_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>
|
</SettingsSidebarSection>
|
||||||
|
|
||||||
{/* Integrations Section */}
|
{/* Integrations Section */}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ describe('SettingsLayout', () => {
|
|||||||
// Reset mock for locked feature tests
|
// Reset mock for locked feature tests
|
||||||
mockCanUse.mockImplementation((feature: string) => {
|
mockCanUse.mockImplementation((feature: string) => {
|
||||||
// Lock specific features
|
// Lock specific features
|
||||||
if (feature === 'white_label') return false;
|
if (feature === 'remove_branding') return false;
|
||||||
if (feature === 'custom_domain') return false;
|
if (feature === 'custom_domain') return false;
|
||||||
if (feature === 'api_access') return false;
|
if (feature === 'api_access') return false;
|
||||||
if (feature === 'custom_oauth') 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();
|
renderWithRouter();
|
||||||
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
||||||
const lockIcons = within(appearanceLink).queryAllByTestId('lock-icon');
|
const lockIcons = within(appearanceLink).queryAllByTestId('lock-icon');
|
||||||
@@ -461,7 +461,7 @@ describe('SettingsLayout', () => {
|
|||||||
|
|
||||||
it('passes isFeatureLocked to child routes when feature is locked', () => {
|
it('passes isFeatureLocked to child routes when feature is locked', () => {
|
||||||
mockCanUse.mockImplementation((feature: string) => {
|
mockCanUse.mockImplementation((feature: string) => {
|
||||||
return feature !== 'white_label';
|
return feature !== 'remove_branding';
|
||||||
});
|
});
|
||||||
|
|
||||||
const ChildComponent = () => {
|
const ChildComponent = () => {
|
||||||
@@ -485,7 +485,7 @@ describe('SettingsLayout', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('is-locked')).toHaveTextContent('true');
|
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', () => {
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Customer, User } from '../types';
|
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 {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
|
||||||
Filter,
|
Filter,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Mail,
|
Mail,
|
||||||
Phone,
|
Phone,
|
||||||
X,
|
X,
|
||||||
Eye
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
FileText,
|
||||||
|
StickyNote,
|
||||||
|
History,
|
||||||
|
Save
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
|
|
||||||
@@ -30,6 +43,15 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
direction: 'asc'
|
direction: 'asc'
|
||||||
});
|
});
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -39,9 +61,71 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
state: '',
|
state: '',
|
||||||
zip: ''
|
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 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) => {
|
const handleSort = (key: keyof Customer) => {
|
||||||
setSortConfig(current => ({
|
setSortConfig(current => ({
|
||||||
@@ -58,31 +142,121 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
const handleAddCustomer = (e: React.FormEvent) => {
|
const handleAddCustomer = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const newCustomer: Partial<Customer> = {
|
createCustomerMutation.mutate({
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
phone: formData.phone,
|
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);
|
setIsAddModalOpen(false);
|
||||||
setFormData({ name: '', email: '', phone: '', tags: '', city: '', state: '', zip: '' });
|
setFormData({ name: '', email: '', phone: '', tags: '', city: '', state: '', zip: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCustomers = useMemo(() => {
|
const handleEditClick = (customer: Customer) => {
|
||||||
let sorted = [...customers];
|
setEditingCustomer(customer);
|
||||||
|
setEditFormData({
|
||||||
|
name: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone || '',
|
||||||
|
isActive: customer.status === 'Active'
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (searchTerm) {
|
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const lowerTerm = searchTerm.toLowerCase();
|
const { name, value, type } = e.target;
|
||||||
sorted = sorted.filter(c =>
|
if (type === 'checkbox') {
|
||||||
c.name.toLowerCase().includes(lowerTerm) ||
|
setEditFormData(prev => ({ ...prev, [name]: (e.target as HTMLInputElement).checked }));
|
||||||
c.email.toLowerCase().includes(lowerTerm) ||
|
} else {
|
||||||
c.phone.includes(searchTerm)
|
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) => {
|
sorted.sort((a, b) => {
|
||||||
const aValue = a[sortConfig.key];
|
const aValue = a[sortConfig.key];
|
||||||
@@ -97,7 +271,7 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [customers, searchTerm, sortConfig]);
|
}, [customers, sortConfig]);
|
||||||
|
|
||||||
// Only owners can masquerade as customers (per backend permissions)
|
// Only owners can masquerade as customers (per backend permissions)
|
||||||
const canMasquerade = effectiveUser.role === 'owner';
|
const canMasquerade = effectiveUser.role === 'owner';
|
||||||
@@ -180,7 +354,11 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
{filteredCustomers.map((customer: any) => {
|
{filteredCustomers.map((customer: any) => {
|
||||||
const customerUser = customer.user_data;
|
const customerUser = customer.user_data;
|
||||||
return (
|
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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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 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">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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 && (
|
{canMasquerade && customerUser && (
|
||||||
<button
|
<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"
|
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')}
|
title={t('common.masqueradeAsUser')}
|
||||||
>
|
>
|
||||||
<Eye size={14} /> {t('common.masquerade')}
|
<Eye size={14} /> {t('common.masquerade')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -222,7 +404,18 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,6 +469,344 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</div>
|
</div>
|
||||||
</Portal>
|
</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>
|
</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,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { useDarkMode, getChartTooltipStyles } from '../hooks/useDarkMode';
|
||||||
|
|
||||||
interface StaffDashboardProps {
|
interface StaffDashboardProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
@@ -59,6 +60,8 @@ interface Appointment {
|
|||||||
|
|
||||||
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||||
const userResourceId = user.linked_resource_id ?? null;
|
const userResourceId = user.linked_resource_id ?? null;
|
||||||
const userResourceName = user.linked_resource_name ?? null;
|
const userResourceName = user.linked_resource_name ?? null;
|
||||||
|
|
||||||
@@ -506,7 +509,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
|||||||
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
|
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={150}>
|
<ResponsiveContainer width="100%" height={256}>
|
||||||
<BarChart data={weeklyChartData}>
|
<BarChart data={weeklyChartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -523,13 +526,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||||
contentStyle={{
|
contentStyle={tooltipStyles.contentStyle}
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
||||||
backgroundColor: '#1F2937',
|
|
||||||
color: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PLATFORM_METRICS } from '../../mockData';
|
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 { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';
|
||||||
|
import { useDarkMode, getChartTooltipStyles } from '../../hooks/useDarkMode';
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ name: 'Jan', mrr: 340000 },
|
{ name: 'Jan', mrr: 340000 },
|
||||||
@@ -17,6 +17,8 @@ const data = [
|
|||||||
|
|
||||||
const PlatformDashboard: React.FC = () => {
|
const PlatformDashboard: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
const tooltipStyles = getChartTooltipStyles(isDark);
|
||||||
const getColorClass = (color: string) => {
|
const getColorClass = (color: string) => {
|
||||||
switch(color) {
|
switch(color) {
|
||||||
case 'blue': return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400';
|
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 */}
|
{/* MRR Chart */}
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
<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>
|
<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]">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={200}>
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
<AreaChart data={data}>
|
<AreaChart data={data}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorMrr" x1="0" y1="0" x2="0" y2="1">
|
<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} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.1} />
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tickFormatter={(val) => `$${val/1000}k`} tick={{ fill: '#9CA3AF' }} />
|
<YAxis axisLine={false} tickLine={false} tickFormatter={(val) => `$${val/1000}k`} tick={{ fill: '#9CA3AF' }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={tooltipStyles.contentStyle}
|
||||||
backgroundColor: '#1F2937',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: '#fff'
|
|
||||||
}}
|
|
||||||
formatter={(val: number) => [`$${val.toLocaleString()}`, 'MRR']}
|
formatter={(val: number) => [`$${val.toLocaleString()}`, 'MRR']}
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="mrr" stroke="#6366f1" fillOpacity={1} fill="url(#colorMrr)" strokeWidth={3} />
|
<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 {
|
return {
|
||||||
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
|
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
|
||||||
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
|
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_accept_payments: getBooleanFeature(features, 'payment_processing'),
|
||||||
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
|
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_api_access: getBooleanFeature(features, 'api_access'),
|
||||||
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
|
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
|
||||||
can_use_sms_reminders: getBooleanFeature(features, 'sms_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 })}
|
onChange={(e) => setInviteForm({ ...inviteForm, can_white_label: e.target.checked })}
|
||||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
webhooks: boolean;
|
||||||
api_access: boolean;
|
api_access: boolean;
|
||||||
custom_domain: boolean;
|
custom_domain: boolean;
|
||||||
white_label: boolean;
|
remove_branding: boolean;
|
||||||
custom_oauth: boolean;
|
custom_oauth: boolean;
|
||||||
automations: boolean;
|
automations: boolean;
|
||||||
can_create_automations: boolean;
|
can_create_automations: boolean;
|
||||||
@@ -271,6 +271,7 @@ export interface Customer {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
userId?: string;
|
userId?: string;
|
||||||
paymentMethods: PaymentMethod[];
|
paymentMethods: PaymentMethod[];
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ FEATURES = [
|
|||||||
{"code": "custom_branding", "name": "Custom Branding", "description": "Customize branding colors, logo, and styling", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_customize_booking_page", "display_order": 20},
|
{"code": "custom_branding", "name": "Custom Branding", "description": "Customize branding colors, logo, and styling", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_customize_booking_page", "display_order": 20},
|
||||||
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
|
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
|
||||||
{"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
|
{"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
|
||||||
{"code": "white_label", "name": "White Label", "description": "Remove all SmoothSchedule branding completely", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 50},
|
|
||||||
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
|
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
|
||||||
|
|
||||||
# --- Automations ---
|
# --- Automations ---
|
||||||
@@ -306,7 +305,6 @@ PLANS = [
|
|||||||
"team_permissions": True,
|
"team_permissions": True,
|
||||||
"audit_logs": True,
|
"audit_logs": True,
|
||||||
"custom_branding": True,
|
"custom_branding": True,
|
||||||
"white_label": True,
|
|
||||||
"remove_branding": True,
|
"remove_branding": True,
|
||||||
"multi_location": True,
|
"multi_location": True,
|
||||||
"priority_support": True,
|
"priority_support": True,
|
||||||
@@ -405,14 +403,13 @@ ADDONS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "white_label_addon",
|
"code": "remove_branding_addon",
|
||||||
"name": "White Label",
|
"name": "Remove Branding",
|
||||||
"description": "Remove all SmoothSchedule branding",
|
"description": "Remove all SmoothSchedule branding from customer-facing pages",
|
||||||
"price_monthly_cents": 9900,
|
"price_monthly_cents": 9900,
|
||||||
"price_one_time_cents": 0, # Recurring only
|
"price_one_time_cents": 0, # Recurring only
|
||||||
"is_stackable": False,
|
"is_stackable": False,
|
||||||
"features": [
|
"features": [
|
||||||
{"code": "white_label", "bool_value": True},
|
|
||||||
{"code": "remove_branding", "bool_value": True},
|
{"code": "remove_branding", "bool_value": True},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-17 00:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0012_create_default_staff_roles'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='notes',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Internal notes about this user (visible to business staff only)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -99,6 +99,11 @@ class User(AbstractUser):
|
|||||||
help_text="Whether user has verified their phone number"
|
help_text="Whether user has verified their phone number"
|
||||||
)
|
)
|
||||||
job_title = models.CharField(max_length=100, blank=True)
|
job_title = models.CharField(max_length=100, blank=True)
|
||||||
|
notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Internal notes about this user (visible to business staff only)"
|
||||||
|
)
|
||||||
|
|
||||||
# Two-Factor Authentication (2FA/MFA) fields
|
# Two-Factor Authentication (2FA/MFA) fields
|
||||||
mfa_enabled = models.BooleanField(
|
mfa_enabled = models.BooleanField(
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ def current_business_view(request):
|
|||||||
'webhooks': tenant.has_feature('integrations_enabled'),
|
'webhooks': tenant.has_feature('integrations_enabled'),
|
||||||
'api_access': tenant.has_feature('api_access'),
|
'api_access': tenant.has_feature('api_access'),
|
||||||
'custom_domain': tenant.has_feature('custom_domain'),
|
'custom_domain': tenant.has_feature('custom_domain'),
|
||||||
'white_label': tenant.has_feature('white_label'),
|
'remove_branding': tenant.has_feature('remove_branding'),
|
||||||
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
||||||
'automations': tenant.has_feature('can_use_automations'),
|
'automations': tenant.has_feature('can_use_automations'),
|
||||||
'can_create_automations': tenant.has_feature('can_create_automations'),
|
'can_create_automations': tenant.has_feature('can_create_automations'),
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
|
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
|
||||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||||
'user_id', 'user_data',
|
'user_id', 'user_data', 'notes',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
@@ -196,7 +196,17 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_last_visit(self, obj):
|
def get_last_visit(self, obj):
|
||||||
# TODO: Get from last appointment when implemented
|
"""Get the date of the last completed appointment for this customer."""
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
last_completed = Event.objects.filter(
|
||||||
|
participants__content_type=user_ct,
|
||||||
|
participants__object_id=obj.id,
|
||||||
|
participants__role=Participant.Role.CUSTOMER,
|
||||||
|
status=Event.Status.COMPLETED
|
||||||
|
).order_by('-start_time').first()
|
||||||
|
|
||||||
|
if last_completed:
|
||||||
|
return last_completed.start_time
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from smoothschedule.communication.notifications.models import Notification
|
from smoothschedule.communication.notifications.models import Notification
|
||||||
@@ -407,6 +408,17 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
).values_list('event_id', flat=True)
|
).values_list('event_id', flat=True)
|
||||||
queryset = queryset.filter(id__in=resource_event_ids)
|
queryset = queryset.filter(id__in=resource_event_ids)
|
||||||
|
|
||||||
|
# Filter by specific customer ID
|
||||||
|
customer_id = self.request.query_params.get('customer')
|
||||||
|
if customer_id:
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
customer_event_ids = Participant.objects.filter(
|
||||||
|
content_type=user_ct,
|
||||||
|
object_id=customer_id,
|
||||||
|
role=Participant.Role.CUSTOMER
|
||||||
|
).values_list('event_id', flat=True)
|
||||||
|
queryset = queryset.filter(id__in=customer_event_ids)
|
||||||
|
|
||||||
# Filter by date range
|
# Filter by date range
|
||||||
start_date = self.request.query_params.get('start_date')
|
start_date = self.request.query_params.get('start_date')
|
||||||
end_date = self.request.query_params.get('end_date')
|
end_date = self.request.query_params.get('end_date')
|
||||||
@@ -667,6 +679,13 @@ class ParticipantViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPagination(PageNumberPagination):
|
||||||
|
"""Pagination for customer list with cursor-based infinite scroll support."""
|
||||||
|
page_size = 25
|
||||||
|
page_size_query_param = 'page_size'
|
||||||
|
max_page_size = 100
|
||||||
|
|
||||||
|
|
||||||
class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint for managing Customers.
|
API endpoint for managing Customers.
|
||||||
@@ -676,15 +695,22 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
Permissions:
|
Permissions:
|
||||||
- Staff members cannot list customers
|
- Staff members cannot list customers
|
||||||
- Staff can only retrieve individual customers with limited fields (name, address)
|
- Staff can only retrieve individual customers with limited fields (name, address)
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- page: Page number for pagination
|
||||||
|
- page_size: Number of items per page (default 25, max 100)
|
||||||
|
- search: Search by name or email
|
||||||
|
- status: Filter by Active/Inactive
|
||||||
"""
|
"""
|
||||||
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||||
serializer_class = CustomerSerializer
|
serializer_class = CustomerSerializer
|
||||||
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
||||||
|
pagination_class = CustomerPagination
|
||||||
|
|
||||||
filterset_fields = ['is_active']
|
filterset_fields = ['is_active']
|
||||||
search_fields = ['email', 'first_name', 'last_name']
|
search_fields = ['email', 'first_name', 'last_name']
|
||||||
ordering_fields = ['email', 'created_at']
|
ordering_fields = ['email', 'created_at', 'first_name', 'last_name']
|
||||||
ordering = ['email']
|
ordering = ['first_name', 'last_name']
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user