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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user