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:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user