import React, { useState, useRef, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useOutletContext } from 'react-router-dom'; import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle, Users, Bell, Mail, MessageSquare, Heart, Check, Phone, Calendar } from 'lucide-react'; import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices'; import { useResources } from '../hooks/useResources'; import { useUpdateBusiness } from '../hooks/useBusiness'; import { Service, User, Business } from '../types'; import { getOverQuotaServiceIds } from '../utils/quotaUtils'; import { CurrencyInput } from '../components/ui'; import CustomerPreview from '../components/services/CustomerPreview'; import ServiceAddonManager from '../components/services/ServiceAddonManager'; interface ServiceFormData { name: string; durationMinutes: number; price_cents: number; // Price in cents (e.g., 5000 = $50.00) description: string; photos: string[]; // Pricing fields variable_pricing: boolean; deposit_enabled: boolean; deposit_type: 'amount' | 'percent'; deposit_amount_cents: number | null; // Deposit in cents (e.g., 2500 = $25.00) deposit_percent: number | null; // Resource assignment fields all_resources: boolean; resource_ids: string[]; // Manual scheduling (unscheduled booking) requires_manual_scheduling: boolean; capture_preferred_time: boolean; // Timing fields prep_time: number; takedown_time: number; // Reminder notification fields reminder_enabled: boolean; reminder_hours_before: number; reminder_email: boolean; reminder_sms: boolean; // Thank you email thank_you_email_enabled: boolean; } // Helper to format cents as dollars for display const formatCentsAsDollars = (cents: number): string => { return (cents / 100).toFixed(2); }; const Services: React.FC = () => { const { t } = useTranslation(); const { user, business } = useOutletContext<{ user: User, business: Business }>(); const { data: services, isLoading, error } = useServices(); const { data: resources } = useResources({ type: 'STAFF' }); // Only STAFF resources for services const createService = useCreateService(); const updateService = useUpdateService(); const deleteService = useDeleteService(); const reorderServices = useReorderServices(); const updateBusiness = useUpdateBusiness(); // Booking page heading customization const [headingText, setHeadingText] = useState(business.serviceSelectionHeading || 'Choose your experience'); const [subheadingText, setSubheadingText] = useState(business.serviceSelectionSubheading || 'Select a service to begin your booking.'); // Update local state when business data changes useEffect(() => { setHeadingText(business.serviceSelectionHeading || 'Choose your experience'); setSubheadingText(business.serviceSelectionSubheading || 'Select a service to begin your booking.'); }, [business.serviceSelectionHeading, business.serviceSelectionSubheading]); const handleSaveHeading = async () => { try { await updateBusiness.mutateAsync({ serviceSelectionHeading: headingText, serviceSelectionSubheading: subheadingText, }); } catch (error) { console.error('Failed to save heading:', error); } }; const handleCancelEditHeading = () => { setHeadingText(business.serviceSelectionHeading || 'Choose your experience'); setSubheadingText(business.serviceSelectionSubheading || 'Select a service to begin your booking.'); }; // Calculate over-quota services (will be auto-archived when grace period ends) const overQuotaServiceIds = useMemo( () => getOverQuotaServiceIds(services || [], user.quota_overages), [services, user.quota_overages] ); const [isModalOpen, setIsModalOpen] = useState(false); const [editingService, setEditingService] = useState(null); const [formData, setFormData] = useState({ name: '', durationMinutes: 60, price_cents: 0, description: '', photos: [], variable_pricing: false, deposit_enabled: false, deposit_type: 'amount', deposit_amount_cents: null, deposit_percent: null, all_resources: true, resource_ids: [], // Timing fields prep_time: 0, takedown_time: 0, // Reminder notification fields reminder_enabled: false, reminder_hours_before: 24, reminder_email: true, reminder_sms: false, // Thank you email thank_you_email_enabled: false, }); // Photo gallery state const [isDraggingPhoto, setIsDraggingPhoto] = useState(false); const [draggedPhotoIndex, setDraggedPhotoIndex] = useState(null); const [dragOverPhotoIndex, setDragOverPhotoIndex] = useState(null); // Drag and drop state const [draggedId, setDraggedId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const [localServices, setLocalServices] = useState(null); const dragNodeRef = useRef(null); // Use local state during drag, otherwise use fetched data const displayServices = localServices ?? services; // Drag handlers const handleDragStart = (e: React.DragEvent, 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, 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) => { 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) => { e.preventDefault(); e.stopPropagation(); setIsDraggingPhoto(true); }; const handlePhotoDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDraggingPhoto(false); }; const handlePhotoUpload = (e: React.ChangeEvent) => { 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, index: number) => { setDraggedPhotoIndex(index); e.dataTransfer.effectAllowed = 'move'; }; const handlePhotoReorderOver = (e: React.DragEvent, 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({ name: '', durationMinutes: 60, price_cents: 0, description: '', photos: [], variable_pricing: false, deposit_enabled: false, deposit_type: 'amount', deposit_amount_cents: null, deposit_percent: null, all_resources: true, resource_ids: [], requires_manual_scheduling: false, capture_preferred_time: true, prep_time: 0, takedown_time: 0, reminder_enabled: false, reminder_hours_before: 24, reminder_email: true, reminder_sms: false, thank_you_email_enabled: false, }); setIsModalOpen(true); }; const openEditModal = (service: Service) => { setEditingService(service); // Determine deposit configuration from existing data const hasDeposit = (service.deposit_amount_cents && service.deposit_amount_cents > 0) || (service.deposit_percent && service.deposit_percent > 0); const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount'; setFormData({ name: service.name, durationMinutes: service.durationMinutes, price_cents: service.price_cents || 0, description: service.description || '', photos: service.photos || [], variable_pricing: service.variable_pricing || false, deposit_enabled: hasDeposit, deposit_type: depositType, deposit_amount_cents: service.deposit_amount_cents || null, deposit_percent: service.deposit_percent || null, all_resources: service.all_resources ?? true, resource_ids: service.resource_ids || [], requires_manual_scheduling: service.requires_manual_scheduling || false, capture_preferred_time: service.capture_preferred_time ?? true, prep_time: service.prep_time || 0, takedown_time: service.takedown_time || 0, reminder_enabled: service.reminder_enabled || false, reminder_hours_before: service.reminder_hours_before || 24, reminder_email: service.reminder_email ?? true, reminder_sms: service.reminder_sms || false, thank_you_email_enabled: service.thank_you_email_enabled || false, }); setIsModalOpen(true); }; const closeModal = () => { setIsModalOpen(false); setEditingService(null); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Build API data based on form state const apiData = { name: formData.name, durationMinutes: formData.durationMinutes, price_cents: formData.variable_pricing ? 0 : formData.price_cents, // Price is 0 for variable pricing description: formData.description, photos: formData.photos, variable_pricing: formData.variable_pricing, // Only send deposit values if deposit is enabled deposit_amount_cents: formData.deposit_enabled && formData.deposit_type === 'amount' ? formData.deposit_amount_cents : null, deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent' ? formData.deposit_percent : null, // Resource assignment all_resources: formData.all_resources, resource_ids: formData.all_resources ? [] : formData.resource_ids, // Manual scheduling (unscheduled booking) requires_manual_scheduling: formData.requires_manual_scheduling, capture_preferred_time: formData.requires_manual_scheduling ? formData.capture_preferred_time : true, // Timing fields prep_time: formData.prep_time, takedown_time: formData.takedown_time, // Reminder fields - only send if enabled reminder_enabled: formData.reminder_enabled, reminder_hours_before: formData.reminder_enabled ? formData.reminder_hours_before : 24, reminder_email: formData.reminder_enabled ? formData.reminder_email : true, reminder_sms: formData.reminder_enabled ? formData.reminder_sms : false, // Thank you email thank_you_email_enabled: formData.thank_you_email_enabled, }; try { if (editingService) { await updateService.mutateAsync({ id: editingService.id, updates: apiData, }); } else { await createService.mutateAsync(apiData); } closeModal(); } catch (error) { console.error('Failed to save service:', error); } }; const handleDelete = async (id: string) => { if (window.confirm(t('services.confirmDelete', 'Are you sure you want to delete this service?'))) { try { await deleteService.mutateAsync(id); } catch (error) { console.error('Failed to delete service:', error); } } }; if (isLoading) { return (
); } if (error) { return (
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
); } return (

{t('services.title', 'Services')}

{t('services.description', 'Manage the services your business offers')}

{displayServices && displayServices.length === 0 ? (
{t('services.noServices', 'No services yet. Add your first service to get started.')}
) : ( <> {/* Booking Page Heading Settings */}

{t('services.bookingPageHeading', 'Booking Page Heading')}

setHeadingText(e.target.value)} className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500" placeholder="Choose your experience" />
setSubheadingText(e.target.value)} className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500" placeholder="Select a service to begin your booking." />
{(headingText !== (business.serviceSelectionHeading || 'Choose your experience') || subheadingText !== (business.serviceSelectionSubheading || 'Select a service to begin your booking.')) && (
)}
{/* Left Column - Editable Services List (1/3 width) */}

{t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}

{displayServices?.map((service) => { const isOverQuota = overQuotaServiceIds.has(service.id); return (
handleDragStart(e, service.id)} onDragEnd={handleDragEnd} onDragOver={(e) => handleDragOver(e, service.id)} onDragLeave={handleDragLeave} className={`p-4 border rounded-xl shadow-sm cursor-move transition-all ${ isOverQuota ? 'bg-amber-50/50 dark:bg-amber-900/10 border-amber-300 dark:border-amber-600 opacity-70' : draggedId === service.id ? 'opacity-50 border-brand-500 bg-white dark:bg-gray-800' : dragOverId === service.id ? 'border-brand-500 ring-2 ring-brand-500/50 bg-white dark:bg-gray-800' : 'border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800' }`} title={isOverQuota ? 'Over quota - will be archived if not resolved' : undefined} >
{isOverQuota ? ( ) : ( )} {/* Service Thumbnail */} {service.photos && service.photos.length > 0 ? (
{service.name}
) : (
)}

{service.name} {isOverQuota && ( Over quota )}

{service.description && (

{service.description}

)}
{service.durationMinutes} {t('common.minutes', 'min')} {service.variable_pricing ? ( <> {t('services.fromPrice', 'From')} ${service.price} ) : ( `$${service.price}` )} {service.variable_pricing && ( {t('services.variablePricingBadge', 'Variable')} )} {service.requires_deposit && ( ${service.deposit_amount} {t('services.depositBadge', 'deposit')} )} {/* Resource assignment indicator */} 0 ? service.resource_names.join(', ') : t('services.noResourcesAssigned', 'No resources assigned') }> {service.all_resources ? t('services.allResourcesBadge', 'All') : service.resource_names?.length || 0}
); })}
{/* Right Column - Customer Preview Mockup (2/3 width) */}

{t('services.customerPreview', 'Customer Preview')}

{/* Lumina-style Customer Preview */}
{/* Preview Header */}

{headingText}

{subheadingText}

{/* 2-Column Grid - Matches Booking Wizard (max-w-5xl = 1024px) */}
{displayServices?.map((service) => { const hasImage = service.photos && service.photos.length > 0; return (
{hasImage && (
{service.name}
)}
{service.name}
{service.description && (

{service.description}

)}
{service.durationMinutes} mins
{service.variable_pricing ? ( Price varies ) : ( <> {service.price} )}
{service.requires_deposit && (
Deposit required: {service.deposit_display}
)}
); })}
{/* Preview Note */}

{t('services.mockupNote', 'Preview only - not clickable')}

)} {/* Modal */} {isModalOpen && (
{/* Left: Form */}

{editingService ? t('services.editService', 'Edit Service') : t('services.addService', 'Add Service')}

{/* Variable Pricing Toggle - At the top */}

{t('services.variablePricingDescription', 'Final price is determined after service completion')}

{/* Name */}
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')} />
{/* Duration and Price */}
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" />
{formData.variable_pricing ? ( ) : ( setFormData({ ...formData, price_cents: cents })} required={!formData.variable_pricing} placeholder="$0.00" 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" /> )} {formData.variable_pricing && (

{t('services.variablePriceNote', 'Price determined after service')}

)}
{/* Deposit Toggle and Configuration */}

{t('services.depositDescription', 'Collect a deposit when customer books')}

{/* Deposit Configuration - only shown when enabled */} {formData.deposit_enabled && (
{/* Deposit Type Selection - only show for fixed pricing */} {!formData.variable_pricing && (
)} {/* Amount Input */} {(formData.variable_pricing || formData.deposit_type === 'amount') && (
setFormData({ ...formData, deposit_amount_cents: cents || null })} required min={1} placeholder="$0.00" 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" />
)} {/* Percent Input - only for fixed pricing */} {!formData.variable_pricing && formData.deposit_type === 'percent' && (
setFormData({ ...formData, deposit_percent: parseFloat(e.target.value) || null })} required min={1} max={100} step={1} 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="25" /> {formData.deposit_percent && formData.price_cents > 0 && (

= ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}

)}
)}

{t('services.depositNote', 'Customers must save a payment method to book this service.')}

)}
{/* Resource Assignment */}
{/* All Resources Toggle */}
{t('services.allResources', 'All Resources')}

{t('services.allResourcesDescription', 'Any resource can be booked for this service')}

{/* Specific Resource Selection */} {!formData.all_resources && (

{t('services.selectSpecificResources', 'Select specific resources that can provide this service:')}

{resources && resources.length > 0 ? (
{resources.map((resource) => ( ))}
) : (

{t('services.noStaffResources', 'No resources available. Add resources first.')}

)} {!formData.all_resources && formData.resource_ids.length === 0 && resources && resources.length > 0 && (

{t('services.selectAtLeastOne', 'Select at least one resource, or enable "All Resources"')}

)}
)}
{/* Manual Scheduling (Unscheduled Booking) */}

{t('services.manualSchedulingDescription', 'Online bookings go to Pending Requests for staff to call and schedule')}

{/* Nested option - Capture Preferred Time */} {formData.requires_manual_scheduling && (
{t('services.capturePreferredTime', 'Ask for Preferred Time')}

{t('services.capturePreferredTimeDescription', 'Let customers indicate when they prefer to be scheduled')}

)} {/* Info box when enabled */} {formData.requires_manual_scheduling && (

{t('services.manualSchedulingNote', 'When a customer books this service, they won\'t select a time slot. The booking goes to Pending Requests for staff to call and schedule manually.')}

)}
{/* Prep Time and Takedown Time */}
setFormData({ ...formData, prep_time: parseInt(e.target.value) || 0 })} min={0} 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="0" />

{t('services.prepTimeHint', 'Time needed before the appointment')}

setFormData({ ...formData, takedown_time: parseInt(e.target.value) || 0 })} min={0} 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="0" />

{t('services.takedownTimeHint', 'Time needed after the appointment')}

{/* Reminder Notifications */}
{formData.reminder_enabled && (
{/* Reminder timing */}
setFormData({ ...formData, reminder_hours_before: parseInt(e.target.value) || 24 })} min={1} max={168} className="w-20 px-3 py-2 border border-amber-300 dark:border-amber-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-amber-500" /> {t('services.hoursBefore', 'hours before appointment')}
{/* Reminder methods */}
)}
{/* Thank You Email */}

{t('services.thankYouEmailDescription', 'Send a follow-up email after the appointment')}

{/* Description */}