Enhance AvailabilityCalendar component with loading state, toast notifications, and delete confirmation modal
- Added loading state to indicate data fetching progress. - Implemented toast notifications for success and error messages during availability operations. - Introduced a delete confirmation modal for improved user experience when removing availability slots. - Enhanced TimeSlotModal with validation error handling and loading indicators for adding time slots.
This commit is contained in:
@@ -5,6 +5,8 @@ import type { User, CalendarEvent } from './types'
|
||||
import { availabilityManagement } from '../../services/supabase'
|
||||
import { BackButton } from './ui/BackButton'
|
||||
import { TimeSlotModal } from './ui/TimeSlotModal'
|
||||
import { Toast, type ToastType } from './ui/Toast'
|
||||
import { DeleteConfirmationModal } from './ui/DeleteConfirmationModal'
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css'
|
||||
import '../CalendarStyles.css'
|
||||
|
||||
@@ -16,6 +18,7 @@ interface AvailabilityCalendarProps {
|
||||
export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps) {
|
||||
const localizer = momentLocalizer(moment)
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [showTimeSlotForm, setShowTimeSlotForm] = useState(false)
|
||||
@@ -26,10 +29,20 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
const [currentView, setCurrentView] = useState(Views.MONTH)
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
|
||||
// Toast notification state
|
||||
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null)
|
||||
|
||||
// Delete confirmation state
|
||||
const [eventToDelete, setEventToDelete] = useState<CalendarEvent | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
// Load availability from DB on mount
|
||||
useEffect(() => {
|
||||
const loadAvailability = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const records = await availabilityManagement.getMyAvailability()
|
||||
const mapped: CalendarEvent[] = records.map(r => ({
|
||||
id: r.id,
|
||||
@@ -41,6 +54,9 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
setEvents(mapped)
|
||||
} catch (e) {
|
||||
console.error('Failed to load availability', e)
|
||||
setToast({ message: 'Failed to load availability. Please refresh the page.', type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadAvailability()
|
||||
@@ -73,24 +89,38 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectEvent = async (event: CalendarEvent) => {
|
||||
if (!window.confirm('Do you want to remove this availability?')) {
|
||||
return
|
||||
}
|
||||
const handleSelectEvent = (event: CalendarEvent) => {
|
||||
setEventToDelete(event)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!eventToDelete) return
|
||||
|
||||
try {
|
||||
if (typeof event.id === 'string') {
|
||||
await availabilityManagement.deleteAvailability(event.id)
|
||||
setIsDeleting(true)
|
||||
if (typeof eventToDelete.id === 'string') {
|
||||
await availabilityManagement.deleteAvailability(eventToDelete.id)
|
||||
}
|
||||
setEvents(events.filter(e => e.id !== event.id))
|
||||
setEvents(events.filter(e => e.id !== eventToDelete.id))
|
||||
setToast({ message: 'Availability removed successfully.', type: 'success' })
|
||||
setEventToDelete(null)
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to delete availability.')
|
||||
setToast({ message: e?.message || 'Failed to delete availability. Please try again.', type: 'error' })
|
||||
console.error('Failed to delete availability', e)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setEventToDelete(null)
|
||||
}
|
||||
|
||||
const handleAddTimeSlot = async () => {
|
||||
if (!selectedDate) return
|
||||
|
||||
setValidationError(null)
|
||||
|
||||
const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number)
|
||||
const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number)
|
||||
|
||||
@@ -100,6 +130,12 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
const endDateTime = new Date(selectedDate)
|
||||
endDateTime.setHours(endHour, endMinute, 0, 0)
|
||||
|
||||
// Validate start time is before end time
|
||||
if (startDateTime >= endDateTime) {
|
||||
setValidationError('End time must be after start time')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for overlapping events
|
||||
const hasOverlap = events.some(event => {
|
||||
const eventStart = new Date(event.start)
|
||||
@@ -113,11 +149,12 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
})
|
||||
|
||||
if (hasOverlap) {
|
||||
alert('This time slot overlaps with an existing availability. Please choose a different time.')
|
||||
setValidationError('This time slot overlaps with an existing availability. Please choose a different time.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAdding(true)
|
||||
// Persist to DB first to get real ID and server validation
|
||||
const created = await availabilityManagement.createAvailability({
|
||||
available_from: startDateTime.toISOString(),
|
||||
@@ -135,15 +172,22 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
setEvents([...events, newEvent])
|
||||
setShowTimeSlotForm(false)
|
||||
setSelectedDate(null)
|
||||
setNewTimeSlot({ startTime: '09:00', endTime: '17:00' })
|
||||
setValidationError(null)
|
||||
setToast({ message: 'Availability added successfully.', type: 'success' })
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to save availability. Please try again.')
|
||||
setToast({ message: e?.message || 'Failed to save availability. Please try again.', type: 'error' })
|
||||
console.error('Failed to create availability', e)
|
||||
} finally {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTimeSlot = () => {
|
||||
setShowTimeSlotForm(false)
|
||||
setSelectedDate(null)
|
||||
setNewTimeSlot({ startTime: '09:00', endTime: '17:00' })
|
||||
setValidationError(null)
|
||||
}
|
||||
|
||||
const getEventsForDate = (date: Date) => {
|
||||
@@ -155,6 +199,26 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{eventToDelete && (
|
||||
<DeleteConfirmationModal
|
||||
title="Remove Availability"
|
||||
message={`Are you sure you want to remove this availability slot on ${moment(eventToDelete.start).format('MMMM D, YYYY')} from ${moment(eventToDelete.start).format('h:mm A')} to ${moment(eventToDelete.end).format('h:mm A')}?`}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -184,34 +248,66 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
|
||||
onTimeSlotChange={setNewTimeSlot}
|
||||
onAdd={handleAddTimeSlot}
|
||||
onCancel={handleCancelTimeSlot}
|
||||
isAdding={isAdding}
|
||||
validationError={validationError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: '100%', minHeight: '600px' }}
|
||||
view={currentView}
|
||||
onView={setCurrentView}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
views={[Views.MONTH, Views.WEEK, Views.DAY]}
|
||||
selectable
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
step={30}
|
||||
timeslots={2}
|
||||
min={new Date(2024, 0, 1, 6, 0)} // 6:00 AM
|
||||
max={new Date(2024, 0, 1, 22, 0)} // 10:00 PM
|
||||
/>
|
||||
</div>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="h-[600px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading availability...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Calendar with optional empty state overlay */
|
||||
<div className="relative h-[600px] flex flex-col">
|
||||
{events.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center bg-white/90 dark:bg-gray-800/90 rounded-lg p-6 backdrop-blur-sm">
|
||||
<div className="mx-auto w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No availability slots yet
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Click and drag on the calendar to add your first availability slot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: '100%', minHeight: '600px' }}
|
||||
view={currentView}
|
||||
onView={setCurrentView}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
views={[Views.MONTH, Views.WEEK, Views.DAY]}
|
||||
selectable
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
step={30}
|
||||
timeslots={2}
|
||||
min={new Date(2024, 0, 1, 6, 0)} // 6:00 AM
|
||||
max={new Date(2024, 0, 1, 22, 0)} // 10:00 PM
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
interface DeleteConfirmationModalProps {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
export function DeleteConfirmationModal({
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting = false
|
||||
}: DeleteConfirmationModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-[999999]">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-900/50 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<div
|
||||
className="relative w-full rounded-2xl bg-white shadow-xl dark:bg-gray-900 max-w-md mx-auto p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white text-center mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface TimeSlotModalProps {
|
||||
onTimeSlotChange: (timeSlot: { startTime: string; endTime: string }) => void
|
||||
onAdd: () => void
|
||||
onCancel: () => void
|
||||
isAdding?: boolean
|
||||
validationError?: string | null
|
||||
}
|
||||
|
||||
export function TimeSlotModal({
|
||||
@@ -19,7 +21,9 @@ export function TimeSlotModal({
|
||||
existingEvents,
|
||||
onTimeSlotChange,
|
||||
onAdd,
|
||||
onCancel
|
||||
onCancel,
|
||||
isAdding = false,
|
||||
validationError = null
|
||||
}: TimeSlotModalProps) {
|
||||
const getEventsForDate = (date: Date) => {
|
||||
return existingEvents.filter(event => {
|
||||
@@ -28,6 +32,18 @@ export function TimeSlotModal({
|
||||
})
|
||||
}
|
||||
|
||||
// Validate time slot
|
||||
const isValid = () => {
|
||||
if (!newTimeSlot.startTime || !newTimeSlot.endTime) {
|
||||
return false
|
||||
}
|
||||
const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number)
|
||||
const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number)
|
||||
const startMinutes = startHour * 60 + startMinute
|
||||
const endMinutes = endHour * 60 + endMinute
|
||||
return startMinutes < endMinutes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
@@ -69,7 +85,12 @@ export function TimeSlotModal({
|
||||
type="time"
|
||||
value={newTimeSlot.startTime}
|
||||
onChange={(e) => onTimeSlotChange({ ...newTimeSlot, startTime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
validationError || !isValid() && newTimeSlot.startTime && newTimeSlot.endTime
|
||||
? 'border-red-300 dark:border-red-600'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -81,8 +102,18 @@ export function TimeSlotModal({
|
||||
type="time"
|
||||
value={newTimeSlot.endTime}
|
||||
onChange={(e) => onTimeSlotChange({ ...newTimeSlot, endTime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
validationError || !isValid() && newTimeSlot.startTime && newTimeSlot.endTime
|
||||
? 'border-red-300 dark:border-red-600'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
{(validationError || (!isValid() && newTimeSlot.startTime && newTimeSlot.endTime)) && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{validationError || 'End time must be after start time'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,15 +136,27 @@ export function TimeSlotModal({
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
disabled={isAdding}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
disabled={!isValid() || isAdding}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
Add Time Slot
|
||||
{isAdding ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
'Add Time Slot'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
85
scheduling-remote/src/components/scheduling/ui/Toast.tsx
Normal file
85
scheduling-remote/src/components/scheduling/ui/Toast.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: ToastType
|
||||
onClose: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function Toast({ message, type, onClose, duration = 5000 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [duration, onClose])
|
||||
|
||||
const typeStyles = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300',
|
||||
error: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-300',
|
||||
}
|
||||
|
||||
const iconStyles = {
|
||||
success: 'text-green-400',
|
||||
error: 'text-red-400',
|
||||
info: 'text-blue-400',
|
||||
warning: 'text-yellow-400',
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-[999999] p-4 rounded-md shadow-lg border ${typeStyles[type]} animate-in slide-in-from-top-5 fade-in duration-300`}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{icons[type]}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`inline-flex rounded-md p-1.5 ${typeStyles[type]} hover:opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800`}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user