feat: Add photo galleries to services, resource types management, and UI improvements

Major features:
- Add drag-and-drop photo gallery to Service create/edit modals
- Add Resource Types management section to Settings (CRUD for custom types)
- Add edit icon consistency to Resources table (pencil icon in actions)
- Improve Services page with drag-to-reorder and customer preview mockup

Backend changes:
- Add photos JSONField to Service model with migration
- Add ResourceType model with category (STAFF/OTHER), description fields
- Add ResourceTypeViewSet with CRUD operations
- Add service reorder endpoint for display order

Frontend changes:
- Services page: two-column layout, drag-reorder, photo upload
- Settings page: Resource Types tab with full CRUD modal
- Resources page: Edit icon in actions column instead of row click
- Sidebar: Payments link visibility based on role and paymentsEnabled
- Update types.ts with Service.photos and ResourceTypeDefinition

Note: Removed photos from ResourceType (kept only for Service)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

View File

@@ -194,29 +194,29 @@ const AppContent: React.FC = () => {
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
};
// Not authenticated - show public routes
if (!user) {
// On root domain, show marketing site
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On root domain, ALWAYS show marketing site (even if logged in)
// Logged-in users will see a "Go to Dashboard" link in the navbar
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout user={user} />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On business subdomain, show login
// Not authenticated on subdomain - show login
if (!user) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -232,6 +232,43 @@ const AppContent: React.FC = () => {
return <ErrorScreen error={userError as Error} />;
}
// Subdomain validation for logged-in users
const currentHostname = window.location.hostname;
const isPlatformDomain = currentHostname === 'platform.lvh.me';
const currentSubdomain = currentHostname.split('.')[0];
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api';
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
const isCustomer = user.role === 'customer';
// RULE: Platform users must be on platform subdomain (not business subdomains)
if (isPlatformUser && isBusinessSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://platform.lvh.me${port}/`;
return <LoadingScreen />;
}
// RULE: Business users must be on their own business subdomain
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// RULE: Customers must be on their business subdomain
if (isCustomer && isPlatformDomain && user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// Handlers
const toggleTheme = () => setDarkMode((prev) => !prev);
const handleSignOut = () => {
@@ -242,22 +279,20 @@ const AppContent: React.FC = () => {
};
const handleMasquerade = (targetUser: any) => {
// Call the masquerade API with the target user's username
// Fallback to email prefix if username is not available
const username = targetUser.username || targetUser.email?.split('@')[0];
if (!username) {
console.error('Cannot masquerade: no username or email available', targetUser);
// Call the masquerade API with the target user's id
const userId = targetUser.id;
if (!userId) {
console.error('Cannot masquerade: no user id available', targetUser);
return;
}
masqueradeMutation.mutate(username);
// Ensure userId is a number
const userPk = typeof userId === 'string' ? parseInt(userId, 10) : userId;
masqueradeMutation.mutate(userPk);
};
// Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
// Platform users (superuser, platform_manager, platform_support)
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
if (isPlatformUser) {
return (
<Routes>
@@ -329,54 +364,16 @@ const AppContent: React.FC = () => {
return <LoadingScreen />;
}
// Check if we're on root/platform domain without proper business context
const currentHostname = window.location.hostname;
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
// Business error or no business found
if (businessError || !business) {
// If user is a business owner on root domain, redirect to their business
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
// If user has a business subdomain, redirect them there
if (user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// If on root/platform and shouldn't be here, show appropriate message
if (isRootOrPlatform) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{user.business_subdomain
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
: 'Your account is not associated with a business. Please contact support.'}
</p>
<div className="flex gap-4 justify-center">
{user.business_subdomain && (
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Go to Business
</button>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
// No business subdomain - show error
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">

View File

@@ -86,15 +86,15 @@ export const refreshToken = async (refresh: string): Promise<{ access: string }>
};
/**
* Masquerade as another user
* Masquerade as another user (hijack)
*/
export const masquerade = async (
username: string,
masquerade_stack?: MasqueradeStackEntry[]
user_pk: number,
hijack_history?: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
`/api/users/${username}/masquerade/`,
{ masquerade_stack }
'/api/auth/hijack/acquire/',
{ user_pk, hijack_history }
);
return response.data;
};
@@ -106,7 +106,7 @@ export const stopMasquerade = async (
masquerade_stack: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
'/api/users/stop_masquerade/',
'/api/auth/hijack/release/',
{ masquerade_stack }
);
return response.data;

View File

@@ -5,14 +5,23 @@
import apiClient from './client';
export interface PlatformBusinessOwner {
id: number;
username: string;
full_name: string;
email: string;
role: string;
}
export interface PlatformBusiness {
id: number;
name: string;
subdomain: string;
tier: string;
is_active: boolean;
created_at: string;
created_on: string;
user_count: number;
owner: PlatformBusinessOwner | null;
}
export interface PlatformUser {

View File

@@ -34,7 +34,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
? location.pathname === path
: location.pathname.startsWith(path);
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
const baseClasses = `flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
const activeClasses = 'bg-white/10 text-white';
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
@@ -70,14 +70,40 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
{!isCollapsed && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
{/* Logo-only mode: full width */}
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
<div className="flex items-center justify-center w-full">
<img
src={business.logoUrl}
alt={business.name}
className="max-w-full max-h-16 object-contain"
/>
</div>
) : (
<>
{/* Logo/Icon display */}
{business.logoUrl && business.logoDisplayMode !== 'text-only' ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
src={business.logoUrl}
alt={business.name}
className="w-full h-full object-contain"
/>
</div>
) : business.logoDisplayMode !== 'logo-only' && (
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
)}
{/* Text display */}
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</>
)}
</button>
@@ -111,19 +137,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<>
{business.paymentsEnabled ? (
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</Link>
) : (
<div
className={getNavClass('/payments', false, true)}
title={t('nav.paymentsDisabledTooltip')}
>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
{/* Payments link: always visible for owners, only visible for others if enabled */}
{(role === 'owner' || business.paymentsEnabled) && (
business.paymentsEnabled ? (
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</Link>
) : (
<div
className={getNavClass('/payments', false, true)}
title={t('nav.paymentsDisabledTooltip')}
>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
)
)}
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
<MessageSquare size={20} className="shrink-0" />
@@ -149,7 +178,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
</nav>
<div className="p-4 border-t border-white/10">
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}>
<a
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 text-xs text-white/60 mb-4 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
>
<SmoothScheduleLogo className="w-6 h-6 text-white" />
{!isCollapsed && (
<div>
@@ -157,7 +191,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<span className="font-semibold text-white/80">Smooth Schedule</span>
</div>
)}
</div>
</a>
<button
onClick={handleSignOut}
disabled={logoutMutation.isPending}

View File

@@ -4,13 +4,15 @@ import { useTranslation } from 'react-i18next';
import { Menu, X, Sun, Moon } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
import LanguageSelector from '../LanguageSelector';
import { User } from '../../api/auth';
interface NavbarProps {
darkMode: boolean;
toggleTheme: () => void;
user?: User | null;
}
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
const { t } = useTranslation();
const location = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -38,6 +40,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
const isActive = (path: string) => location.pathname === path;
// Get the dashboard URL based on user role
const getDashboardUrl = (): string => {
if (!user) return '/login';
const port = window.location.port ? `:${window.location.port}` : '';
const protocol = window.location.protocol;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
return `${protocol}//platform.lvh.me${port}/`;
}
if (user.business_subdomain) {
return `${protocol}//${user.business_subdomain}.lvh.me${port}/`;
}
return '/login';
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
@@ -90,12 +107,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
</button>
{/* Login Button - Hidden on mobile */}
<Link
to="/login"
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</Link>
{user ? (
<a
href={getDashboardUrl()}
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</a>
) : (
<Link
to="/login"
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</Link>
)}
{/* Get Started CTA */}
<Link
@@ -139,12 +165,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
</Link>
))}
<hr className="my-2 border-gray-200 dark:border-gray-800" />
<Link
to="/login"
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</Link>
{user ? (
<a
href={getDashboardUrl()}
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</a>
) : (
<Link
to="/login"
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</Link>
)}
<Link
to="/signup"
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"

View File

@@ -101,13 +101,13 @@ export const useMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (username: string) => {
mutationFn: async (user_pk: number) => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
// Call masquerade API with current stack
return masquerade(username, currentStack);
return masquerade(user_pk, currentStack);
},
onSuccess: async (data) => {
// Store the updated masquerading stack

View File

@@ -33,6 +33,8 @@ export const useCurrentBusiness = () => {
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
logoUrl: data.logo_url,
emailLogoUrl: data.email_logo_url,
logoDisplayMode: data.logo_display_mode || 'text-only',
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
status: data.status,
@@ -64,6 +66,8 @@ export const useUpdateBusiness = () => {
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
if (updates.emailLogoUrl !== undefined) backendData.email_logo_url = updates.emailLogoUrl;
if (updates.logoDisplayMode !== undefined) backendData.logo_display_mode = updates.logoDisplayMode;
if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled;
}
@@ -136,7 +140,7 @@ export const useBusinessUsers = () => {
return useQuery({
queryKey: ['businessUsers'],
queryFn: async () => {
const { data } = await apiClient.get('/api/business/users/');
const { data } = await apiClient.get('/api/staff/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes

View File

@@ -0,0 +1,91 @@
/**
* Resource Types Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { ResourceTypeDefinition } from '../types';
/**
* Hook to fetch resource types for the current business
*/
export const useResourceTypes = () => {
return useQuery<ResourceTypeDefinition[]>({
queryKey: ['resourceTypes'],
queryFn: async () => {
const { data } = await apiClient.get('/api/resource-types/');
return data;
},
// Provide default types if API doesn't have them yet
placeholderData: [
{
id: 'default-staff',
name: 'Staff',
category: 'STAFF',
isDefault: true,
},
{
id: 'default-room',
name: 'Room',
category: 'OTHER',
isDefault: true,
},
{
id: 'default-equipment',
name: 'Equipment',
category: 'OTHER',
isDefault: true,
},
] as ResourceTypeDefinition[],
});
};
/**
* Hook to create a new resource type
*/
export const useCreateResourceType = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newType: Omit<ResourceTypeDefinition, 'id' | 'isDefault'>) => {
const { data } = await apiClient.post('/api/resource-types/', newType);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
},
});
};
/**
* Hook to update a resource type
*/
export const useUpdateResourceType = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<ResourceTypeDefinition> }) => {
const { data } = await apiClient.patch(`/api/resource-types/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
},
});
};
/**
* Hook to delete a resource type
*/
export const useDeleteResourceType = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/resource-types/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
},
});
};

View File

@@ -28,6 +28,8 @@ export const useResources = (filters?: ResourceFilters) => {
name: r.name,
type: r.type as ResourceType,
userId: r.user_id ? String(r.user_id) : undefined,
maxConcurrentEvents: r.max_concurrent_events ?? 1,
savedLaneCount: r.saved_lane_count,
}));
},
});
@@ -47,6 +49,8 @@ export const useResource = (id: string) => {
name: data.name,
type: data.type as ResourceType,
userId: data.user_id ? String(data.user_id) : undefined,
maxConcurrentEvents: data.max_concurrent_events ?? 1,
savedLaneCount: data.saved_lane_count,
};
},
enabled: !!id,
@@ -91,6 +95,12 @@ export const useUpdateResource = () => {
if (updates.userId !== undefined) {
backendData.user = updates.userId ? parseInt(updates.userId) : null;
}
if (updates.maxConcurrentEvents !== undefined) {
backendData.max_concurrent_events = updates.maxConcurrentEvents;
}
if (updates.savedLaneCount !== undefined) {
backendData.saved_lane_count = updates.savedLaneCount;
}
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
return data;

View File

@@ -22,6 +22,8 @@ export const useServices = () => {
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
description: s.description || '',
displayOrder: s.display_order ?? 0,
photos: s.photos || [],
}));
},
retry: false, // Don't retry on 404 - endpoint may not exist yet
@@ -43,6 +45,8 @@ export const useService = (id: string) => {
durationMinutes: data.duration || data.duration_minutes,
price: parseFloat(data.price),
description: data.description || '',
displayOrder: data.display_order ?? 0,
photos: data.photos || [],
};
},
enabled: !!id,
@@ -63,6 +67,7 @@ export const useCreateService = () => {
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
description: serviceData.description,
photos: serviceData.photos || [],
};
const { data } = await apiClient.post('/api/services/', backendData);
@@ -87,6 +92,7 @@ export const useUpdateService = () => {
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price) backendData.price = updates.price.toString();
if (updates.description !== undefined) backendData.description = updates.description;
if (updates.photos !== undefined) backendData.photos = updates.photos;
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
return data;
@@ -112,3 +118,22 @@ export const useDeleteService = () => {
},
});
};
/**
* Hook to reorder services (drag and drop)
*/
export const useReorderServices = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (orderedIds: string[]) => {
// Convert string IDs to numbers for the backend
const order = orderedIds.map(id => parseInt(id, 10));
const { data } = await apiClient.post('/api/services/reorder/', { order });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};

View File

@@ -0,0 +1,42 @@
/**
* Staff Management Hooks
*/
import { useQuery } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface StaffMember {
id: string;
name: string;
email: string;
phone?: string;
}
interface StaffFilters {
search?: string;
}
/**
* Hook to fetch staff members with optional filters
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
*/
export const useStaff = (filters?: StaffFilters) => {
return useQuery<StaffMember[]>({
queryKey: ['staff', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
const { data } = await apiClient.get(`/api/staff/?${params}`);
// Transform backend format to frontend format
return data.map((s: any) => ({
id: String(s.id),
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
email: s.email || '',
phone: s.phone || '',
}));
},
retry: false,
});
};

View File

@@ -3,8 +3,13 @@ import { Outlet } from 'react-router-dom';
import Navbar from '../components/marketing/Navbar';
import Footer from '../components/marketing/Footer';
import { useScrollToTop } from '../hooks/useScrollToTop';
import { User } from '../api/auth';
const MarketingLayout: React.FC = () => {
interface MarketingLayoutProps {
user?: User | null;
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ user }) => {
useScrollToTop();
const [darkMode, setDarkMode] = useState(() => {
@@ -28,7 +33,7 @@ const MarketingLayout: React.FC = () => {
return (
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} />
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} user={user} />
{/* Main Content - with padding for fixed navbar */}
<main className="flex-1 pt-16 lg:pt-20">

View File

@@ -99,7 +99,8 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
return sorted;
}, [customers, searchTerm, sortConfig]);
const canMasquerade = ['owner', 'manager', 'staff'].includes(effectiveUser.role);
// Only owners can masquerade as customers (per backend permissions)
const canMasquerade = effectiveUser.role === 'owner';
if (isLoading) {
return (

View File

@@ -33,40 +33,68 @@ const LoginPage: React.FC = () => {
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
const portStr = currentPort ? `:${currentPort}` : '';
// Check if we're on the root domain (no subdomain)
// Check domain type
const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost';
const isPlatformDomain = currentHostname === 'platform.lvh.me';
const currentSubdomain = currentHostname.split('.')[0];
const isBusinessSubdomain = !isRootDomain && !isPlatformDomain && currentSubdomain !== 'api';
// Roles allowed to login at the root domain
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
// Platform users (superuser, platform_manager, platform_support)
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
// If on root domain, only allow specific roles
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
setError(t('auth.loginAtSubdomain'));
// Business-associated users (owner, manager, staff, resource)
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
// Customer users
const isCustomer = user.role === 'customer';
// RULE 1: Platform users cannot login on business subdomains
if (isPlatformUser && isBusinessSubdomain) {
setError(t('auth.invalidCredentials'));
return;
}
// Determine the correct subdomain based on user role
// RULE 2: Business users cannot login on other business subdomains
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) {
setError(t('auth.invalidCredentials'));
return;
}
// RULE 3: Customers cannot login on root domain (they must use their business subdomain)
if (isCustomer && isRootDomain) {
setError(t('auth.invalidCredentials'));
return;
}
// RULE 4: Customers cannot login on platform domain
if (isCustomer && isPlatformDomain) {
setError(t('auth.invalidCredentials'));
return;
}
// RULE 5: Customers cannot login on a different business subdomain
if (isCustomer && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) {
setError(t('auth.invalidCredentials'));
return;
}
// Determine target subdomain for redirect
let targetSubdomain: string | null = null;
// Platform users (superuser, platform_manager, platform_support)
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
if (isPlatformUser) {
targetSubdomain = 'platform';
}
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
// Check if we need to redirect to a different subdomain
// Need to redirect if we have a target subdomain AND we're not already on it
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
if (needsRedirect) {
// Pass tokens in URL to ensure they're available immediately on the new subdomain
// This avoids race conditions where cookies might not be set before the page loads
const portStr = currentPort ? `:${currentPort}` : '';
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
return;
}

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useEffect, useState, useRef } from 'react';
import React, { useMemo, useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ResourceType, User, Resource } from '../types';
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
@@ -15,7 +15,8 @@ import {
Eye,
Calendar,
Settings,
X
X,
Pencil
} from 'lucide-react';
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
@@ -55,17 +56,29 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
// Staff selection state
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
const [staffSearchQuery, setStaffSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [showStaffDropdown, setShowStaffDropdown] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const staffInputRef = useRef<HTMLInputElement>(null);
const staffDropdownRef = useRef<HTMLDivElement>(null);
// Fetch staff members for autocomplete
const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery });
// Debounce search query for API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(staffSearchQuery);
}, 300);
return () => clearTimeout(timer);
}, [staffSearchQuery]);
// Fetch staff members for autocomplete (using debounced query)
const { data: staffMembers = [] } = useStaff({ search: debouncedSearchQuery });
// Filter staff members based on search query (client-side filtering for immediate feedback)
const filteredStaff = useMemo(() => {
if (!staffSearchQuery) return staffMembers;
const query = staffSearchQuery.toLowerCase();
// Always show all staff when dropdown is open and no search query
if (!staffSearchQuery.trim()) return staffMembers;
const query = staffSearchQuery.toLowerCase().trim();
return staffMembers.filter(
(s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query)
);
@@ -76,6 +89,59 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
return staffMembers.find((s) => s.id === selectedStaffId) || null;
}, [staffMembers, selectedStaffId]);
// Get the list that's currently displayed in the dropdown
const displayedStaff = useMemo(() => {
return staffSearchQuery.trim() === '' ? staffMembers : filteredStaff;
}, [staffSearchQuery, staffMembers, filteredStaff]);
// Reset highlighted index when filtered staff changes
useEffect(() => {
setHighlightedIndex(-1);
}, [filteredStaff]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showStaffDropdown || displayedStaff.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((prev) =>
prev < displayedStaff.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < displayedStaff.length) {
const staff = displayedStaff[highlightedIndex];
setSelectedStaffId(staff.id);
setStaffSearchQuery(staff.name);
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}
break;
case 'Escape':
e.preventDefault();
setShowStaffDropdown(false);
setHighlightedIndex(-1);
break;
}
};
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex >= 0 && staffDropdownRef.current) {
const highlightedElement = staffDropdownRef.current.children[highlightedIndex] as HTMLElement;
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [highlightedIndex]);
const createResourceMutation = useCreateResource();
const updateResourceMutation = useUpdateResource();
@@ -97,6 +163,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
}, [allAppointments]);
// Reset form when modal opens/closes or editing resource changes
// NOTE: Only depend on editingResource and isModalOpen, NOT staffMembers
// to avoid clearing the form when staff data updates during search
useEffect(() => {
if (editingResource) {
setFormType(editingResource.type);
@@ -108,28 +176,40 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
// Pre-fill staff if editing a STAFF resource
if (editingResource.type === 'STAFF' && editingResource.userId) {
setSelectedStaffId(editingResource.userId);
// Find the staff member to set the initial search query (display name)
const staff = staffMembers.find(s => s.id === editingResource.userId);
setStaffSearchQuery(staff ? staff.name : '');
// We'll set the staff name in a separate effect
} else {
setSelectedStaffId(null);
setStaffSearchQuery('');
}
} else {
} else if (isModalOpen) {
// Only reset when creating new (modal opened without editing resource)
setFormType('STAFF');
setFormName('');
setFormDescription('');
setFormMaxConcurrent(1);
setFormMultilaneEnabled(false);
setFormSavedLaneCount(undefined);
setSelectedStaffId(null); // Clear selected staff when creating new
setSelectedStaffId(null);
setStaffSearchQuery('');
setDebouncedSearchQuery('');
}
}, [editingResource, staffMembers]);
}, [editingResource, isModalOpen]);
// Separate effect to populate staff name when editing
// This runs when staffMembers loads and we have a selected staff ID
useEffect(() => {
if (editingResource && editingResource.type === 'STAFF' && editingResource.userId && selectedStaffId === editingResource.userId) {
const staff = staffMembers.find(s => s.id === editingResource.userId);
if (staff && !staffSearchQuery) {
// Only set if not already set to avoid overwriting user input
setStaffSearchQuery(staff.name);
}
}
}, [staffMembers, editingResource, selectedStaffId, staffSearchQuery]);
const openCreateModal = () => {
setEditingResource(null);
setIsModalOpen(true);
setEditingResource(null);
};
const openEditModal = (resource: Resource) => {
@@ -138,8 +218,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
};
const closeModal = () => {
setIsModalOpen(false);
setEditingResource(null);
setIsModalOpen(false);
};
const handleMultilaneToggle = (enabled: boolean) => {
@@ -251,8 +331,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
return (
<tr
key={resource.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer"
onClick={() => openEditModal(resource)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
@@ -297,7 +376,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
@@ -305,6 +384,13 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
>
<Eye size={14} /> {t('resources.viewCalendar')}
</button>
<button
onClick={() => openEditModal(resource)}
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
</div>
</td>
</tr>
@@ -324,7 +410,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
{isModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div key={editingResource?.id || 'new'} className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingResource ? t('resources.editResource') : t('resources.addNewResource')}
@@ -338,7 +424,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
{/* Resource Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.resourceType')}
{t('resources.resourceType')} <span className="text-red-500">*</span>
</label>
<select
value={formType}
@@ -346,9 +432,12 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
setFormType(e.target.value as ResourceType);
setSelectedStaffId(null); // Clear staff selection if type changes
setStaffSearchQuery('');
setDebouncedSearchQuery('');
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600"
disabled={!!editingResource}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
required
>
<option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option>
@@ -370,47 +459,80 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
onChange={(e) => {
setStaffSearchQuery(e.target.value);
setShowStaffDropdown(true);
setHighlightedIndex(-1);
// Clear selection when user types
if (selectedStaffId) {
setSelectedStaffId(null);
}
}}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowStaffDropdown(true);
setHighlightedIndex(-1);
}}
onBlur={() => {
// Delay to allow click on dropdown
setTimeout(() => {
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}, 200);
}}
onFocus={() => setShowStaffDropdown(true)}
onBlur={() => setTimeout(() => setShowStaffDropdown(false), 150)} // Delay to allow click on dropdown
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600"
placeholder={t('resources.searchStaffPlaceholder')}
required={formType === 'STAFF'}
autoComplete="off"
aria-autocomplete="list"
aria-controls="staff-suggestions"
aria-expanded={showStaffDropdown}
aria-activedescendant={highlightedIndex >= 0 ? `staff-option-${highlightedIndex}` : undefined}
/>
{showStaffDropdown && filteredStaff.length > 0 && (
{showStaffDropdown && displayedStaff.length > 0 && (
<div
ref={staffDropdownRef}
id="staff-suggestions"
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-60 overflow-auto"
role="listbox"
>
{filteredStaff.map((staff) => (
{displayedStaff.map((staff, index) => (
<div
key={staff.id}
className="p-2 text-sm text-gray-900 dark:text-white hover:bg-brand-50 dark:hover:bg-brand-900/30 cursor-pointer"
id={`staff-option-${index}`}
className={`p-2 text-sm text-gray-900 dark:text-white cursor-pointer transition-colors ${
index === highlightedIndex
? 'bg-brand-100 dark:bg-brand-900/50'
: selectedStaffId === staff.id
? 'bg-brand-50 dark:bg-brand-900/30'
: 'hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
onClick={() => {
setSelectedStaffId(staff.id);
setStaffSearchQuery(staff.name);
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
// Prevent input blur on mousedown
e.preventDefault();
}}
role="option"
aria-selected={selectedStaffId === staff.id}
>
{staff.name} ({staff.email})
<div className="font-medium">{staff.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{staff.email}</div>
</div>
))}
</div>
)}
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery.trim() !== '' && filteredStaff.length === 0 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{t('resources.noMatchingStaff')}
</p>
)}
{formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('resources.staffRequired')}
{selectedStaffId && selectedStaff && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full"></span>
Selected: {selectedStaff.name}
</p>
)}
</div>

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2 } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService } from '../hooks/useServices';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
import { Service } from '../types';
interface ServiceFormData {
@@ -9,6 +9,7 @@ interface ServiceFormData {
durationMinutes: number;
price: number;
description: string;
photos: string[];
}
const Services: React.FC = () => {
@@ -17,6 +18,7 @@ const Services: React.FC = () => {
const createService = useCreateService();
const updateService = useUpdateService();
const deleteService = useDeleteService();
const reorderServices = useReorderServices();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
@@ -25,8 +27,165 @@ const Services: React.FC = () => {
durationMinutes: 60,
price: 0,
description: '',
photos: [],
});
// Photo gallery state
const [isDraggingPhoto, setIsDraggingPhoto] = useState(false);
const [draggedPhotoIndex, setDraggedPhotoIndex] = useState<number | null>(null);
const [dragOverPhotoIndex, setDragOverPhotoIndex] = useState<number | null>(null);
// Drag and drop state
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [localServices, setLocalServices] = useState<Service[] | null>(null);
const dragNodeRef = useRef<HTMLDivElement | null>(null);
// Use local state during drag, otherwise use fetched data
const displayServices = localServices ?? services;
// Drag handlers
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
setDraggedId(serviceId);
dragNodeRef.current = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
// Add a slight delay to allow the drag image to be set
setTimeout(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.5';
}
}, 0);
};
const handleDragEnd = () => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '1';
}
setDraggedId(null);
setDragOverId(null);
dragNodeRef.current = null;
// If we have local changes, save them
if (localServices) {
const orderedIds = localServices.map(s => s.id);
reorderServices.mutate(orderedIds, {
onSettled: () => {
setLocalServices(null);
}
});
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
e.preventDefault();
if (draggedId === serviceId) return;
setDragOverId(serviceId);
// Reorder locally for visual feedback
const currentServices = localServices ?? services ?? [];
const draggedIndex = currentServices.findIndex(s => s.id === draggedId);
const targetIndex = currentServices.findIndex(s => s.id === serviceId);
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) return;
const newServices = [...currentServices];
const [removed] = newServices.splice(draggedIndex, 1);
newServices.splice(targetIndex, 0, removed);
setLocalServices(newServices);
};
const handleDragLeave = () => {
setDragOverId(null);
};
// Photo upload handlers
const handlePhotoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
photos: [...prev.photos, reader.result as string],
}));
};
reader.readAsDataURL(file);
}
});
}
};
const handlePhotoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(true);
};
const handlePhotoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(false);
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
photos: [...prev.photos, reader.result as string],
}));
};
reader.readAsDataURL(file);
}
});
}
// Reset input
e.target.value = '';
};
const removePhoto = (index: number) => {
setFormData((prev) => ({
...prev,
photos: prev.photos.filter((_, i) => i !== index),
}));
};
// Photo reorder drag handlers
const handlePhotoReorderStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
setDraggedPhotoIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handlePhotoReorderOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault();
if (draggedPhotoIndex === null || draggedPhotoIndex === index) return;
setDragOverPhotoIndex(index);
// Reorder photos
const newPhotos = [...formData.photos];
const [removed] = newPhotos.splice(draggedPhotoIndex, 1);
newPhotos.splice(index, 0, removed);
setFormData((prev) => ({ ...prev, photos: newPhotos }));
setDraggedPhotoIndex(index);
};
const handlePhotoReorderEnd = () => {
setDraggedPhotoIndex(null);
setDragOverPhotoIndex(null);
};
const openCreateModal = () => {
setEditingService(null);
setFormData({
@@ -34,6 +193,7 @@ const Services: React.FC = () => {
durationMinutes: 60,
price: 0,
description: '',
photos: [],
});
setIsModalOpen(true);
};
@@ -45,6 +205,7 @@ const Services: React.FC = () => {
durationMinutes: service.durationMinutes,
price: service.price,
description: service.description || '',
photos: service.photos || [],
});
setIsModalOpen(true);
};
@@ -122,7 +283,7 @@ const Services: React.FC = () => {
</button>
</div>
{services && services.length === 0 ? (
{displayServices && displayServices.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
<div className="text-gray-500 dark:text-gray-400 mb-4">
{t('services.noServices', 'No services yet. Add your first service to get started.')}
@@ -136,60 +297,149 @@ const Services: React.FC = () => {
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services?.map((service) => (
<div
key={service.id}
className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm"
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(service)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</button>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Editable Services List */}
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}
</p>
<div className="space-y-3">
{displayServices?.map((service) => (
<div
key={service.id}
draggable
onDragStart={(e) => handleDragStart(e, service.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, service.id)}
onDragLeave={handleDragLeave}
className={`p-4 bg-white dark:bg-gray-800 border rounded-xl shadow-sm cursor-move transition-all ${
draggedId === service.id
? 'opacity-50 border-brand-500'
: dragOverId === service.id
? 'border-brand-500 ring-2 ring-brand-500/50'
: 'border-gray-100 dark:border-gray-700'
}`}
>
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{service.name}
</h3>
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
onClick={() => openEditModal(service)}
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-1">
{service.description}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{service.durationMinutes} {t('common.minutes', 'min')}
</span>
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
${service.price.toFixed(2)}
</span>
{service.photos && service.photos.length > 0 && (
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Image className="h-3.5 w-3.5" />
{service.photos.length}
</span>
)}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{service.description}
</p>
)}
{/* Right Column - Customer Preview Mockup */}
<div>
<div className="flex items-center gap-2 mb-4">
<Eye className="h-5 w-5 text-gray-500 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('services.customerPreview', 'Customer Preview')}
</h3>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<Clock className="h-4 w-4" />
<span>{service.durationMinutes} {t('common.minutes', 'min')}</span>
{/* Mockup Container - styled like a booking widget */}
<div className="sticky top-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Mockup Header */}
<div className="bg-brand-600 px-6 py-4">
<h4 className="text-white font-semibold text-lg">{t('services.selectService', 'Select a Service')}</h4>
<p className="text-white/70 text-sm">{t('services.chooseFromMenu', 'Choose from our available services')}</p>
</div>
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<DollarSign className="h-4 w-4" />
<span>${service.price.toFixed(2)}</span>
{/* Services List */}
<div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
{displayServices?.map((service) => (
<div
key={`preview-${service.id}`}
className="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer group"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h5 className="font-medium text-gray-900 dark:text-white truncate">
{service.name}
</h5>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-1">
{service.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-sm">
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{service.durationMinutes} min
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">
${service.price.toFixed(2)}
</span>
</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0 ml-4" />
</div>
</div>
))}
</div>
{/* Mockup Footer */}
<div className="bg-gray-50 dark:bg-gray-900/50 px-6 py-3 text-center border-t border-gray-100 dark:border-gray-700">
<p className="text-xs text-gray-400 dark:text-gray-500">
{t('services.mockupNote', 'Preview only - not clickable')}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingService
? t('services.editService', 'Edit Service')
@@ -203,66 +453,157 @@ const Services: React.FC = () => {
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.name', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
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-brand-500"
placeholder={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="p-6 space-y-4 overflow-y-auto flex-1">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} *
{t('services.name', 'Name')} *
</label>
<input
type="number"
value={formData.durationMinutes}
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
min={5}
step={5}
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-brand-500"
placeholder={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} *
</label>
<input
type="number"
value={formData.durationMinutes}
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
required
min={5}
step={5}
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-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} *
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
required
min={0}
step={0.01}
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-brand-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} *
{t('services.descriptionLabel', 'Description')}
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
required
min={0}
step={0.01}
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-brand-500"
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
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-brand-500 resize-none"
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
/>
</div>
{/* Photo Gallery */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('services.photos', 'Photos')}
</label>
{/* Photo Grid */}
{formData.photos.length > 0 && (
<div className="grid grid-cols-4 gap-3 mb-3">
{formData.photos.map((photo, index) => (
<div
key={index}
draggable
onDragStart={(e) => handlePhotoReorderStart(e, index)}
onDragOver={(e) => handlePhotoReorderOver(e, index)}
onDragEnd={handlePhotoReorderEnd}
className={`relative group aspect-square rounded-lg overflow-hidden border-2 cursor-move transition-all ${
draggedPhotoIndex === index
? 'opacity-50 border-brand-500'
: dragOverPhotoIndex === index
? 'border-brand-500 ring-2 ring-brand-500/50'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<img
src={photo}
alt={`Photo ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<div className="absolute top-1 left-1 text-white/70">
<GripVertical className="h-4 w-4" />
</div>
<button
type="button"
onClick={() => removePhoto(index)}
className="p-1.5 bg-red-500 hover:bg-red-600 text-white rounded-full transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="absolute bottom-1 right-1 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded">
{index + 1}
</div>
</div>
))}
</div>
)}
{/* Drop Zone */}
<div
onDrop={handlePhotoDrop}
onDragOver={handlePhotoDragOver}
onDragLeave={handlePhotoDragLeave}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDraggingPhoto
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
}`}
>
<ImagePlus className={`mx-auto mb-2 h-8 w-8 ${isDraggingPhoto ? 'text-brand-500' : 'text-gray-400'}`} />
<p className={`text-sm ${isDraggingPhoto ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'}`}>
{isDraggingPhoto ? t('services.dropImagesHere', 'Drop images here') : t('services.dragAndDropImages', 'Drag and drop images here, or')}
</p>
{!isDraggingPhoto && (
<>
<input
type="file"
id="service-photo-upload"
className="hidden"
accept="image/*"
multiple
onChange={handlePhotoUpload}
/>
<label
htmlFor="service-photo-upload"
className="inline-flex items-center gap-1 mt-2 px-3 py-1.5 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 cursor-pointer"
>
<Upload className="h-3.5 w-3.5" />
{t('services.browseFiles', 'browse files')}
</label>
</>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{t('services.photosHint', 'Drag photos to reorder. First photo is the primary image.')}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.descriptionLabel', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
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-brand-500 resize-none"
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}

View File

@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Business, User, CustomDomain } from '../types';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet } from 'lucide-react';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil } from 'lucide-react';
import DomainPurchase from '../components/DomainPurchase';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
import OnboardingWizard from '../components/OnboardingWizard';
// Curated color palettes with complementary primary and secondary colors
@@ -18,6 +19,55 @@ const colorPalettes = [
secondary: '#0ea5e9',
preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
},
{
name: 'Sky Blue',
description: 'Light & airy',
primary: '#0ea5e9',
secondary: '#38bdf8',
preview: 'bg-gradient-to-br from-sky-500 to-sky-400',
},
{
name: 'Cyan Splash',
description: 'Modern & vibrant',
primary: '#06b6d4',
secondary: '#22d3ee',
preview: 'bg-gradient-to-br from-cyan-500 to-cyan-400',
},
{
name: 'Aqua Fresh',
description: 'Clean & refreshing',
primary: '#14b8a6',
secondary: '#2dd4bf',
preview: 'bg-gradient-to-br from-teal-500 to-teal-400',
},
{
name: 'Mint Green',
description: 'Soft & welcoming',
primary: '#10b981',
secondary: '#34d399',
preview: 'bg-gradient-to-br from-emerald-500 to-emerald-400',
},
{
name: 'Coral Reef',
description: 'Warm & inviting',
primary: '#f97316',
secondary: '#fb923c',
preview: 'bg-gradient-to-br from-orange-500 to-orange-400',
},
{
name: 'Lavender Dream',
description: 'Gentle & elegant',
primary: '#a78bfa',
secondary: '#c4b5fd',
preview: 'bg-gradient-to-br from-violet-400 to-violet-300',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#ec4899',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-500 to-pink-400',
},
{
name: 'Forest Green',
description: 'Natural & calming',
@@ -32,20 +82,6 @@ const colorPalettes = [
secondary: '#a78bfa',
preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
},
{
name: 'Sunset Orange',
description: 'Energetic & warm',
primary: '#ea580c',
secondary: '#f97316',
preview: 'bg-gradient-to-br from-orange-600 to-amber-500',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#db2777',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-600 to-pink-400',
},
{
name: 'Slate Gray',
description: 'Minimal & sophisticated',
@@ -53,13 +89,6 @@ const colorPalettes = [
secondary: '#64748b',
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
},
{
name: 'Teal Wave',
description: 'Fresh & balanced',
primary: '#0d9488',
secondary: '#14b8a6',
preview: 'bg-gradient-to-br from-teal-600 to-teal-400',
},
{
name: 'Crimson Red',
description: 'Bold & dynamic',
@@ -69,7 +98,256 @@ const colorPalettes = [
},
];
type SettingsTab = 'general' | 'domains' | 'authentication';
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
// Resource Types Management Section Component
const ResourceTypesSection: React.FC = () => {
const { t } = useTranslation();
const { data: resourceTypes = [], isLoading } = useResourceTypes();
const createResourceType = useCreateResourceType();
const updateResourceType = useUpdateResourceType();
const deleteResourceType = useDeleteResourceType();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingType, setEditingType] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'OTHER' as 'STAFF' | 'OTHER',
iconName: '',
});
const openCreateModal = () => {
setEditingType(null);
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
setIsModalOpen(true);
};
const openEditModal = (type: any) => {
setEditingType(type);
setFormData({
name: type.name,
description: type.description || '',
category: type.category,
iconName: type.icon_name || type.iconName || '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingType(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingType) {
await updateResourceType.mutateAsync({
id: editingType.id,
updates: formData,
});
} else {
await createResourceType.mutateAsync(formData);
}
closeModal();
} catch (error) {
console.error('Failed to save resource type:', error);
}
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
try {
await deleteResourceType.mutateAsync(id);
} catch (error: any) {
alert(error.response?.data?.error || 'Failed to delete resource type');
}
}
};
return (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Layers size={20} className="text-indigo-500" />
{t('settings.resourceTypes', 'Resource Types')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.resourceTypesDescription', 'Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment)')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Plus size={16} />
{t('settings.addResourceType', 'Add Type')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : resourceTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Layers size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
</div>
) : (
<div className="space-y-3">
{resourceTypes.map((type: any) => {
const isDefault = type.is_default || type.isDefault;
return (
<div
key={type.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
{type.name}
{isDefault && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Default
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
</p>
{type.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{type.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<button
onClick={() => openEditModal(type)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
{!isDefault && (
<button
onClick={() => handleDelete(type.id, type.name)}
disabled={deleteResourceType.isPending}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
title={t('common.delete', 'Delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Modal for Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingType
? t('settings.editResourceType', 'Edit Resource Type')
: t('settings.addResourceType', 'Add Resource Type')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeName', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
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-brand-500"
placeholder={t('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
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-brand-500 resize-none"
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeCategory', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
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-brand-500"
>
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formData.category === 'STAFF'
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
</p>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createResourceType.isPending || updateResourceType.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
@@ -115,6 +393,16 @@ const SettingsPage: React.FC = () => {
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
const [showOnboarding, setShowOnboarding] = useState(false);
// Drag and drop state for logo uploads
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
const [isDraggingEmailLogo, setIsDraggingEmailLogo] = useState(false);
// Lightbox state for viewing logos
const [lightboxImage, setLightboxImage] = useState<{ url: string; title: string } | null>(null);
// Email preview modal state
const [showEmailPreview, setShowEmailPreview] = useState(false);
// Update OAuth settings when data loads
useEffect(() => {
if (oauthData?.businessSettings) {
@@ -159,11 +447,77 @@ const SettingsPage: React.FC = () => {
}
};
// Drag and drop handlers for logo upload
const handleLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(true);
};
const handleLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
};
const handleLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
// Drag and drop handlers for email logo upload
const handleEmailLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(true);
};
const handleEmailLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
};
const handleEmailLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
const handleSave = () => {
updateBusiness(formState);
setShowToast(true);
};
const handleCancel = () => {
setFormState(business);
};
const handleOAuthSave = () => {
updateOAuthMutation.mutate(oauthSettings, {
onSuccess: () => {
@@ -289,25 +643,17 @@ const SettingsPage: React.FC = () => {
// Tab configuration
const tabs = [
{ id: 'general' as const, label: 'General', icon: Building2 },
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
{ id: 'domains' as const, label: 'Domains', icon: Globe },
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
];
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="p-8 max-w-4xl mx-auto pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
</div>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Save size={18} />
{t('common.saveChanges')}
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
</div>
{/* Tab Navigation */}
@@ -367,6 +713,270 @@ const SettingsPage: React.FC = () => {
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
</h3>
{/* Logo Upload */}
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Image size={16} className="text-blue-500" />
Brand Logos
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Upload your logos for different purposes. PNG with transparent background recommended.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Website Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Website Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in sidebar and customer-facing pages
</p>
<div
onDragOver={handleLogoDragOver}
onDragLeave={handleLogoDragLeave}
onDrop={handleLogoDrop}
className={`transition-all ${isDraggingLogo ? 'scale-105' : ''}`}
>
{formState.logoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.logoUrl}
alt="Business logo"
onClick={() => setLightboxImage({ url: formState.logoUrl!, title: 'Website Logo' })}
className="w-32 h-32 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({
...prev,
logoUrl: undefined,
logoDisplayMode: 'logo-and-text' // Reset to show icon with text
}));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={32} className="mx-auto mb-2" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.logoUrl ? 'Change Logo' : 'Upload Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG, JPG, or SVG. Recommended: 500x500px
</p>
</div>
{/* Logo Display Mode */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Mode
</label>
<select
name="logoDisplayMode"
value={formState.logoDisplayMode || 'text-only'}
onChange={handleChange}
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"
>
<option value="text-only">Text Only</option>
<option value="logo-only">Logo Only</option>
<option value="logo-and-text">Logo and Text</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
How your branding appears in the sidebar
</p>
</div>
</div>
{/* Email Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Email Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in email notifications and receipts
</p>
<div
onDragOver={handleEmailLogoDragOver}
onDragLeave={handleEmailLogoDragLeave}
onDrop={handleEmailLogoDrop}
className={`transition-all ${isDraggingEmailLogo ? 'scale-105' : ''}`}
>
{formState.emailLogoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.emailLogoUrl}
alt="Email logo"
onClick={() => setLightboxImage({ url: formState.emailLogoUrl!, title: 'Email Logo' })}
className="w-48 h-16 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({ ...prev, emailLogoUrl: undefined }));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove email logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-48 h-16 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingEmailLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={24} className="mx-auto mb-1" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="email-logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="email-logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.emailLogoUrl ? 'Change Email Logo' : 'Upload Email Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG with transparent background. Recommended: 600x200px
</p>
<button
type="button"
onClick={() => setShowEmailPreview(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium mt-3"
>
<Eye size={16} />
Preview Email
</button>
</div>
</div>
</div>
{/* Sidebar Preview */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sidebar Preview
</label>
<div
className="w-full max-w-xs p-6 rounded-xl"
style={{ backgroundColor: formState.primaryColor }}
>
<div className="flex items-center gap-3">
{/* Logo-only mode: full width */}
{formState.logoDisplayMode === 'logo-only' && formState.logoUrl ? (
<div className="flex items-center justify-center w-full">
<img
src={formState.logoUrl}
alt={formState.name}
className="max-w-full max-h-16 object-contain"
/>
</div>
) : (
<>
{/* Logo/Icon display - only show if NOT text-only mode */}
{formState.logoDisplayMode !== 'text-only' && (
formState.logoUrl ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
src={formState.logoUrl}
alt={formState.name}
className="w-full h-full object-contain"
/>
</div>
) : (
<div
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0"
style={{ color: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
)
)}
{/* Text display - only show if NOT logo-only mode */}
{formState.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate text-white">{formState.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</>
)}
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
This is how your branding will appear in the navigation sidebar
</p>
</div>
</div>
{/* Color Palette Selection */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
@@ -593,6 +1203,11 @@ const SettingsPage: React.FC = () => {
</>
)}
{/* RESOURCES TAB */}
{activeTab === 'resources' && isOwner && (
<ResourceTypesSection />
)}
{/* DOMAINS TAB */}
{activeTab === 'domains' && (
<>
@@ -1103,6 +1718,185 @@ const SettingsPage: React.FC = () => {
}}
/>
)}
{/* Lightbox Modal for Logo Preview */}
{lightboxImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setLightboxImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between mb-4 text-white">
<h3 className="text-lg font-semibold">{lightboxImage.title}</h3>
<button
onClick={() => setLightboxImage(null)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div
className="bg-white dark:bg-gray-800 rounded-lg p-8 overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<img
src={lightboxImage.url}
alt={lightboxImage.title}
className="max-w-full max-h-[70vh] object-contain mx-auto"
/>
</div>
<p className="text-white text-sm mt-4 text-center">
Click anywhere outside to close
</p>
</div>
</div>
)}
{/* Email Preview Modal */}
{showEmailPreview && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setShowEmailPreview(false)}
>
<div
className="relative max-w-2xl w-full bg-white dark:bg-gray-800 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<button
onClick={() => setShowEmailPreview(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{/* Email Template Preview */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-8" style={{ fontFamily: 'Arial, sans-serif' }}>
{/* Email Header with Logo */}
<div className="bg-white dark:bg-gray-800 rounded-t-lg p-6 text-center border-b-4" style={{ borderBottomColor: formState.primaryColor }}>
{formState.emailLogoUrl ? (
<img
src={formState.emailLogoUrl}
alt={formState.name}
className="mx-auto max-h-20 object-contain"
/>
) : (
<div className="flex items-center justify-center">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-full text-white font-bold text-2xl"
style={{ backgroundColor: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
</div>
)}
</div>
{/* Email Body */}
<div className="bg-white dark:bg-gray-800 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Appointment Confirmation
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Hi John Doe,
</p>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Your appointment has been confirmed. Here are the details:
</p>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Service</p>
<p className="text-gray-900 dark:text-white">Haircut & Style</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Date & Time</p>
<p className="text-gray-900 dark:text-white">Dec 15, 2025 at 2:00 PM</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Duration</p>
<p className="text-gray-900 dark:text-white">60 minutes</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Price</p>
<p className="text-gray-900 dark:text-white">$45.00</p>
</div>
</div>
</div>
<button
className="w-full text-white font-semibold py-3 px-6 rounded-lg transition-colors"
style={{ backgroundColor: formState.primaryColor }}
>
View Appointment Details
</button>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6">
Need to make changes? You can reschedule or cancel up to 24 hours before your appointment.
</p>
</div>
{/* Email Footer */}
<div className="bg-gray-100 dark:bg-gray-900 rounded-b-lg p-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">
{formState.name}
</p>
<p className="text-gray-500 dark:text-gray-500 text-xs">
{business.subdomain}.smoothschedule.com
</p>
<p className="text-gray-400 dark:text-gray-600 text-xs mt-4">
© 2025 {formState.name}. All rights reserved.
</p>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
This is a preview of how your appointment confirmation emails will appear to customers.
</p>
</div>
</div>
</div>
)}
{/* Floating Action Buttons */}
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{showToast && (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle size={16} />
Changes saved successfully
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCancel}
className="flex items-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
>
<X size={18} />
Cancel Changes
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-medium"
>
<Save size={18} />
Save Changes
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -97,8 +97,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{staffUsers.map((user: any) => {
const linkedResource = getLinkedResource(user.id);
// Owners/Managers can log in as anyone.
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id;
// Only owners can masquerade as staff (per backend permissions)
const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id;
return (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">

View File

@@ -2,11 +2,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
import { User } from '../../types';
import { useBusinesses } from '../../hooks/usePlatform';
interface PlatformBusinessesProps {
onMasquerade: (targetUser: User) => void;
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
}
const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => {
@@ -22,19 +21,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
const handleLoginAs = (business: any) => {
// Use the owner data from the API response
if (business.owner) {
const targetOwner: User = {
id: business.owner.id.toString(),
// Pass owner info to masquerade - we only need the id
onMasquerade({
id: business.owner.id,
username: business.owner.username,
name: business.owner.name,
name: business.owner.full_name,
email: business.owner.email,
role: business.owner.role,
business_id: business.id.toString(),
business_subdomain: business.subdomain,
is_active: true,
is_staff: false,
is_superuser: false,
};
onMasquerade(targetOwner);
});
}
};
@@ -130,14 +124,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
</div>
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{new Date(biz.created_at).toLocaleDateString()}
{new Date(biz.created_on).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleLoginAs(biz)}
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 mr-2"
disabled={!biz.owner}
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.name}`}
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.full_name}`}
>
<Eye size={14} /> {t('platform.masquerade')}
</button>

View File

@@ -2,11 +2,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
import { User } from '../../types';
import { usePlatformUsers } from '../../hooks/usePlatform';
interface PlatformUsersProps {
onMasquerade: (targetUser: User) => void;
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
}
const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
@@ -36,20 +35,14 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
};
const handleMasquerade = (platformUser: any) => {
// Convert platform user to User type for masquerade
const targetUser: User = {
id: platformUser.id.toString(),
// Pass user info to masquerade - we only need the id
onMasquerade({
id: platformUser.id,
username: platformUser.username,
name: platformUser.name || platformUser.username,
name: platformUser.full_name || platformUser.username,
email: platformUser.email,
role: platformUser.role || 'customer',
business_id: platformUser.business?.toString() || null,
business_subdomain: platformUser.business_subdomain || null,
is_active: platformUser.is_active,
is_staff: platformUser.is_staff,
is_superuser: platformUser.is_superuser,
};
onMasquerade(targetUser);
});
};
if (isLoading) {

View File

@@ -38,6 +38,8 @@ export interface Business {
primaryColor: string;
secondaryColor: string;
logoUrl?: string;
emailLogoUrl?: string;
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
whitelabelEnabled: boolean;
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
status?: 'Active' | 'Suspended' | 'Trial';
@@ -58,6 +60,7 @@ export interface Business {
isTrialActive?: boolean;
isTrialExpired?: boolean;
daysLeftInTrial?: number;
resourceTypes?: ResourceTypeDefinition[]; // Custom resource types
}
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
@@ -87,11 +90,25 @@ export interface User {
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
export type ResourceTypeCategory = 'STAFF' | 'OTHER';
export interface ResourceTypeDefinition {
id: string;
name: string; // User-facing name like "Stylist", "Massage Therapist", "Treatment Room"
description?: string; // Description of this resource type
category: ResourceTypeCategory; // STAFF (requires staff assignment) or OTHER
isDefault: boolean; // Cannot be deleted
iconName?: string; // Optional icon identifier
}
export interface Resource {
id: string;
name: string;
type: ResourceType;
type: ResourceType; // Legacy field - will be deprecated
typeId?: string; // New field - references ResourceTypeDefinition
userId?: string;
maxConcurrentEvents: number;
savedLaneCount?: number; // Remembered lane count when multilane is disabled
}
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
@@ -147,6 +164,8 @@ export interface Service {
durationMinutes: number;
price: number;
description: string;
displayOrder: number;
photos?: string[];
}
export interface Metric {