Service Addons: - Add ServiceAddon model with optional resource assignment - Create AddonSelection component for booking flow - Add ServiceAddonManager for service configuration - Include addon API endpoints and serializers Manual Scheduling: - Add requires_manual_scheduling and capture_preferred_time to Service model - Add preferred_datetime and preferred_time_notes to Event model - Create ManualSchedulingRequest component for booking callback flow - Auto-open pending sidebar when requests exist or arrive via websocket - Show preferred times on pending items with detail modal popup - Add interactive UnscheduledBookingDemo component for help docs Scheduler Improvements: - Consolidate Create/EditAppointmentModal into single AppointmentModal - Update pending sidebar to show preferred schedule info - Add modal for pending request details with Schedule Now action Documentation: - Add Manual Scheduling section to HelpScheduler with interactive demo - Add Manual Scheduling section to HelpServices with interactive demo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1466 lines
71 KiB
TypeScript
1466 lines
71 KiB
TypeScript
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<Service | null>(null);
|
|
const [formData, setFormData] = useState<ServiceFormData>({
|
|
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<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({
|
|
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 (
|
|
<div className="p-8">
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8">
|
|
<div className="text-center text-red-600 dark:text-red-400">
|
|
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{t('services.title', 'Services')}
|
|
</h2>
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
{t('services.description', 'Manage the services your business offers')}
|
|
</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"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('services.addService', 'Add Service')}
|
|
</button>
|
|
</div>
|
|
|
|
{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.')}
|
|
</div>
|
|
<button
|
|
onClick={openCreateModal}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('services.addService', 'Add Service')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Booking Page Heading Settings */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
|
{t('services.bookingPageHeading', 'Booking Page Heading')}
|
|
</h4>
|
|
<div className="flex flex-wrap gap-4 items-end">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
|
{t('services.heading', 'Heading')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={headingText}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
|
{t('services.subheading', 'Subheading')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={subheadingText}
|
|
onChange={(e) => 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."
|
|
/>
|
|
</div>
|
|
{(headingText !== (business.serviceSelectionHeading || 'Choose your experience') ||
|
|
subheadingText !== (business.serviceSelectionSubheading || 'Select a service to begin your booking.')) && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleSaveHeading}
|
|
disabled={updateBusiness.isPending}
|
|
className="flex items-center gap-1 px-3 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{updateBusiness.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Check className="h-4 w-4" />
|
|
)}
|
|
{t('common.save', 'Save')}
|
|
</button>
|
|
<button
|
|
onClick={handleCancelEditHeading}
|
|
className="flex items-center gap-1 px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-sm rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Left Column - Editable Services List (1/3 width) */}
|
|
<div className="lg:col-span-1">
|
|
<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) => {
|
|
const isOverQuota = overQuotaServiceIds.has(service.id);
|
|
return (
|
|
<div
|
|
key={service.id}
|
|
draggable
|
|
onDragStart={(e) => 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}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isOverQuota ? (
|
|
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
|
|
) : (
|
|
<GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
|
|
)}
|
|
{/* Service Thumbnail */}
|
|
{service.photos && service.photos.length > 0 ? (
|
|
<div className="w-12 h-12 rounded-lg overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-700">
|
|
<img
|
|
src={service.photos[0]}
|
|
alt={service.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
|
<Image className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className={`font-semibold truncate ${isOverQuota ? 'text-amber-800 dark:text-amber-300' : 'text-gray-900 dark:text-white'}`}>
|
|
{service.name}
|
|
{isOverQuota && (
|
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400">
|
|
Over quota
|
|
</span>
|
|
)}
|
|
</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.variable_pricing ? (
|
|
<>
|
|
{t('services.fromPrice', 'From')} ${service.price}
|
|
</>
|
|
) : (
|
|
`$${service.price}`
|
|
)}
|
|
</span>
|
|
{service.variable_pricing && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-800/50 dark:text-purple-300">
|
|
{t('services.variablePricingBadge', 'Variable')}
|
|
</span>
|
|
)}
|
|
{service.requires_deposit && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-800/50 dark:text-blue-300">
|
|
${service.deposit_amount} {t('services.depositBadge', 'deposit')}
|
|
</span>
|
|
)}
|
|
{/* Resource assignment indicator */}
|
|
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1" title={
|
|
service.all_resources
|
|
? t('services.allResourcesAssigned', 'All resources can provide this service')
|
|
: service.resource_names && service.resource_names.length > 0
|
|
? service.resource_names.join(', ')
|
|
: t('services.noResourcesAssigned', 'No resources assigned')
|
|
}>
|
|
<Users className="h-3.5 w-3.5" />
|
|
{service.all_resources
|
|
? t('services.allResourcesBadge', 'All')
|
|
: service.resource_names?.length || 0}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - Customer Preview Mockup (2/3 width) */}
|
|
<div className="lg:col-span-2">
|
|
<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>
|
|
|
|
{/* Lumina-style Customer Preview */}
|
|
<div className="sticky top-8">
|
|
{/* Preview Header */}
|
|
<div className="text-center mb-6">
|
|
<h4 className="text-xl font-bold text-gray-900 dark:text-white">
|
|
{headingText}
|
|
</h4>
|
|
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm">
|
|
{subheadingText}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 2-Column Grid - Matches Booking Wizard (max-w-5xl = 1024px) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl max-h-[600px] overflow-y-auto pr-1">
|
|
{displayServices?.map((service) => {
|
|
const hasImage = service.photos && service.photos.length > 0;
|
|
return (
|
|
<div
|
|
key={`preview-${service.id}`}
|
|
className="relative overflow-hidden rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-lg transition-all duration-200 cursor-pointer group bg-white dark:bg-gray-800"
|
|
>
|
|
<div className="flex h-full min-h-[140px]">
|
|
{hasImage && (
|
|
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
|
<img
|
|
src={service.photos[0]}
|
|
alt={service.name}
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
|
|
<div>
|
|
<h5 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{service.name}
|
|
</h5>
|
|
{service.description && (
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
|
{service.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center justify-between text-sm">
|
|
<div className="flex items-center text-gray-600 dark:text-gray-400">
|
|
<Clock className="w-4 h-4 mr-1.5" />
|
|
{service.durationMinutes} mins
|
|
</div>
|
|
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
|
|
{service.variable_pricing ? (
|
|
<span className="text-purple-600 dark:text-purple-400 text-xs">Price varies</span>
|
|
) : (
|
|
<>
|
|
<DollarSign className="w-4 h-4" />
|
|
{service.price}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{service.requires_deposit && (
|
|
<div className="mt-2 text-xs text-brand-600 dark:text-brand-400 font-medium">
|
|
Deposit required: {service.deposit_display}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Preview Note */}
|
|
<div className="mt-4 text-center">
|
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
|
{t('services.mockupNote', 'Preview only - not clickable')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-5xl h-[90vh] flex flex-col md:flex-row overflow-hidden">
|
|
{/* Left: Form */}
|
|
<div className="w-full md:w-1/2 flex flex-col h-full overflow-hidden border-r border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
|
{editingService
|
|
? t('services.editService', 'Edit Service')
|
|
: t('services.addService', 'Add Service')}
|
|
</h3>
|
|
<button
|
|
onClick={closeModal}
|
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 md:hidden"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
|
{/* Variable Pricing Toggle - At the top */}
|
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<label className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
|
{t('services.variablePricing', 'Variable Pricing')}
|
|
</label>
|
|
<p className="text-xs text-purple-600 dark:text-purple-300 mt-0.5">
|
|
{t('services.variablePricingDescription', 'Final price is determined after service completion')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({
|
|
...formData,
|
|
variable_pricing: !formData.variable_pricing,
|
|
// When enabling variable pricing, switch deposit to amount only
|
|
deposit_type: !formData.variable_pricing ? 'amount' : formData.deposit_type,
|
|
deposit_percent: !formData.variable_pricing ? null : formData.deposit_percent,
|
|
})}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.variable_pricing ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.variable_pricing ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<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>
|
|
|
|
{/* Duration and Price */}
|
|
<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 (Minutes)')} *
|
|
</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')} {!formData.variable_pricing && '*'}
|
|
</label>
|
|
{formData.variable_pricing ? (
|
|
<input
|
|
type="text"
|
|
value=""
|
|
disabled
|
|
placeholder={t('services.priceNA', 'N/A')}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white cursor-not-allowed"
|
|
/>
|
|
) : (
|
|
<CurrencyInput
|
|
value={formData.price_cents}
|
|
onChange={(cents) => 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 && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{t('services.variablePriceNote', 'Price determined after service')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deposit Toggle and Configuration */}
|
|
<div 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-center justify-between">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{t('services.requireDeposit', 'Require Deposit')}
|
|
</label>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{t('services.depositDescription', 'Collect a deposit when customer books')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({
|
|
...formData,
|
|
deposit_enabled: !formData.deposit_enabled,
|
|
deposit_amount_cents: !formData.deposit_enabled ? 5000 : null, // $50.00 default
|
|
deposit_percent: null,
|
|
})}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.deposit_enabled ? 'bg-brand-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.deposit_enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Deposit Configuration - only shown when enabled */}
|
|
{formData.deposit_enabled && (
|
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600 space-y-3">
|
|
{/* Deposit Type Selection - only show for fixed pricing */}
|
|
{!formData.variable_pricing && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('services.depositType', 'Deposit Type')}
|
|
</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="deposit_type"
|
|
checked={formData.deposit_type === 'amount'}
|
|
onChange={() => setFormData({
|
|
...formData,
|
|
deposit_type: 'amount',
|
|
deposit_percent: null,
|
|
deposit_amount_cents: formData.deposit_amount_cents || 5000,
|
|
})}
|
|
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('services.fixedAmount', 'Fixed Amount')}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="deposit_type"
|
|
checked={formData.deposit_type === 'percent'}
|
|
onChange={() => setFormData({
|
|
...formData,
|
|
deposit_type: 'percent',
|
|
deposit_amount_cents: null,
|
|
deposit_percent: formData.deposit_percent || 25,
|
|
})}
|
|
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('services.percentage', 'Percentage')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Amount Input */}
|
|
{(formData.variable_pricing || formData.deposit_type === 'amount') && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('services.depositAmount', 'Deposit Amount')} *
|
|
</label>
|
|
<CurrencyInput
|
|
value={formData.deposit_amount_cents || 0}
|
|
onChange={(cents) => 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"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Percent Input - only for fixed pricing */}
|
|
{!formData.variable_pricing && formData.deposit_type === 'percent' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('services.depositPercent', 'Deposit Percentage (%)')} *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.deposit_percent || ''}
|
|
onChange={(e) => 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 && (
|
|
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
|
= ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t('services.depositNote', 'Customers must save a payment method to book this service.')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Resource Assignment */}
|
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
|
<label className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
|
{t('services.resourceAssignment', 'Who Can Provide This Service?')}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* All Resources Toggle */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<span className="text-sm text-blue-800 dark:text-blue-200">
|
|
{t('services.allResources', 'All Resources')}
|
|
</span>
|
|
<p className="text-xs text-blue-600 dark:text-blue-300">
|
|
{t('services.allResourcesDescription', 'Any resource can be booked for this service')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({
|
|
...formData,
|
|
all_resources: !formData.all_resources,
|
|
resource_ids: !formData.all_resources ? [] : formData.resource_ids,
|
|
})}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.all_resources ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.all_resources ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Specific Resource Selection */}
|
|
{!formData.all_resources && (
|
|
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-600">
|
|
<p className="text-xs text-blue-600 dark:text-blue-300 mb-2">
|
|
{t('services.selectSpecificResources', 'Select specific resources that can provide this service:')}
|
|
</p>
|
|
{resources && resources.length > 0 ? (
|
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
{resources.map((resource) => (
|
|
<label
|
|
key={resource.id}
|
|
className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-blue-100 dark:hover:bg-blue-800/30 transition-colors"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.resource_ids.includes(resource.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setFormData({
|
|
...formData,
|
|
resource_ids: [...formData.resource_ids, resource.id],
|
|
});
|
|
} else {
|
|
setFormData({
|
|
...formData,
|
|
resource_ids: formData.resource_ids.filter(id => id !== resource.id),
|
|
});
|
|
}
|
|
}}
|
|
className="w-4 h-4 text-blue-600 border-blue-300 rounded focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-blue-900 dark:text-blue-100">
|
|
{resource.name}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-blue-600 dark:text-blue-300 italic">
|
|
{t('services.noStaffResources', 'No resources available. Add resources first.')}
|
|
</p>
|
|
)}
|
|
{!formData.all_resources && formData.resource_ids.length === 0 && resources && resources.length > 0 && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
|
{t('services.selectAtLeastOne', 'Select at least one resource, or enable "All Resources"')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Manual Scheduling (Unscheduled Booking) */}
|
|
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-700">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Phone className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
|
<div>
|
|
<label className="text-sm font-medium text-orange-900 dark:text-orange-100">
|
|
{t('services.requiresManualScheduling', 'Requires Manual Scheduling')}
|
|
</label>
|
|
<p className="text-xs text-orange-600 dark:text-orange-300">
|
|
{t('services.manualSchedulingDescription', 'Online bookings go to Pending Requests for staff to call and schedule')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({ ...formData, requires_manual_scheduling: !formData.requires_manual_scheduling })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.requires_manual_scheduling ? 'bg-orange-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.requires_manual_scheduling ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nested option - Capture Preferred Time */}
|
|
{formData.requires_manual_scheduling && (
|
|
<div className="mt-3 pt-3 border-t border-orange-200 dark:border-orange-600">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4 text-orange-500 dark:text-orange-400" />
|
|
<div>
|
|
<span className="text-sm text-orange-800 dark:text-orange-200">
|
|
{t('services.capturePreferredTime', 'Ask for Preferred Time')}
|
|
</span>
|
|
<p className="text-xs text-orange-600 dark:text-orange-300">
|
|
{t('services.capturePreferredTimeDescription', 'Let customers indicate when they prefer to be scheduled')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({ ...formData, capture_preferred_time: !formData.capture_preferred_time })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.capture_preferred_time ? 'bg-orange-500' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.capture_preferred_time ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info box when enabled */}
|
|
{formData.requires_manual_scheduling && (
|
|
<div className="mt-3 p-2 bg-orange-100 dark:bg-orange-800/30 rounded-lg">
|
|
<p className="text-xs text-orange-800 dark:text-orange-200">
|
|
{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.')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Prep Time and Takedown Time */}
|
|
<div 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-center gap-2 mb-3">
|
|
<Clock className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{t('services.bufferTime', 'Buffer Time')}
|
|
</label>
|
|
</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.prepTime', 'Prep Time (Minutes)')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.prep_time}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{t('services.prepTimeHint', 'Time needed before the appointment')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('services.takedownTime', 'Takedown Time (Minutes)')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.takedown_time}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{t('services.takedownTimeHint', 'Time needed after the appointment')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reminder Notifications */}
|
|
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-700">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
|
<label className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
|
{t('services.reminderNotifications', 'Reminder Notifications')}
|
|
</label>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({ ...formData, reminder_enabled: !formData.reminder_enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.reminder_enabled ? 'bg-amber-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.reminder_enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{formData.reminder_enabled && (
|
|
<div className="space-y-4 pt-3 border-t border-amber-200 dark:border-amber-600">
|
|
{/* Reminder timing */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">
|
|
{t('services.reminderTiming', 'Send reminder')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
value={formData.reminder_hours_before}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
|
{t('services.hoursBefore', 'hours before appointment')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reminder methods */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
|
{t('services.reminderMethod', 'Send via')}
|
|
</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.reminder_email}
|
|
onChange={(e) => setFormData({ ...formData, reminder_email: e.target.checked })}
|
|
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
|
|
/>
|
|
<Mail className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
|
{t('services.email', 'Email')}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.reminder_sms}
|
|
onChange={(e) => setFormData({ ...formData, reminder_sms: e.target.checked })}
|
|
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
|
|
/>
|
|
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
|
{t('services.sms', 'Text Message')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thank You Email */}
|
|
<div className="p-4 bg-pink-50 dark:bg-pink-900/20 rounded-lg border border-pink-200 dark:border-pink-700">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" />
|
|
<div>
|
|
<label className="text-sm font-medium text-pink-900 dark:text-pink-100">
|
|
{t('services.thankYouEmail', 'Thank You Email')}
|
|
</label>
|
|
<p className="text-xs text-pink-600 dark:text-pink-300">
|
|
{t('services.thankYouEmailDescription', 'Send a follow-up email after the appointment')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({ ...formData, thank_you_email_enabled: !formData.thank_you_email_enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.thank_you_email_enabled ? 'bg-pink-600' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.thank_you_email_enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<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>
|
|
|
|
{/* 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>
|
|
|
|
{/* Service Addons - Only show when editing an existing service */}
|
|
{editingService && (
|
|
<ServiceAddonManager
|
|
serviceId={parseInt(editingService.id)}
|
|
serviceName={editingService.name}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 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-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={createService.isPending || updateService.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 flex items-center gap-2"
|
|
>
|
|
{(createService.isPending || updateService.isPending) && (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
)}
|
|
{editingService ? t('common.save', 'Save') : t('common.create', 'Create')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Right: Preview (Hidden on mobile) */}
|
|
<div className="hidden md:flex md:w-1/2 bg-gray-50 dark:bg-gray-900/50 flex-col">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('services.customerPreview', 'Customer Preview')}
|
|
</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>
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<CustomerPreview
|
|
service={editingService}
|
|
business={business}
|
|
previewData={{
|
|
name: formData.name,
|
|
description: formData.description,
|
|
durationMinutes: formData.durationMinutes,
|
|
price: formData.price_cents / 100,
|
|
variable_pricing: formData.variable_pricing,
|
|
photos: formData.photos,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Services;
|