feat(bookings): add dry_run slot picker with live validation
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Implemented complete slot picker flow with real-time validation: **Components Created:** 1. **SlotPickerTile** (src/components/bookings/SlotPickerTile.tsx) - Individual slot tile with dry_run validation - 4 validation states: idle, validating, valid, invalid - Real-time API calls to validateMoveBooking() on selection - Visual feedback: checkmark (✓) for valid, X for invalid, spinner during validation - Displays validation errors inline with bullet points - Shows success message "✓ Valid - ready to confirm" - Integrates CapacityDisplay component - Color-coded borders (green=valid, red=invalid, gray=idle) - Disabled state for full/cancelled slots - onValidationResult callback for parent coordination 2. **SlotPickerModal** (src/components/bookings/SlotPickerModal.tsx) - Full-screen modal for slot selection - Groups slots by date (weekday, month, day, year) - Sorts dates chronologically - Filters out current slot - 2-column grid layout for slots - Info banner with policy hints (14-day window, product-type, club match) - Confirm button disabled until valid slot selected - Loading states with spinner - Empty state with helpful message - Tracks validation state from child tiles 3. **CancelBookingModal** (src/components/bookings/CancelBookingModal.tsx) - Confirmation modal for booking cancellation - Optional reason textarea (500 char limit) - Displays grace period policy (reads from booking.policies or defaults to 15 min) - Shows booking details (court, time, attendees count) - Warning banner: "This action cannot be undone" - Policy info banner with grace period details - Two-button layout: "Keep Booking" / "Yes, Cancel Booking" - Loading state with spinner **Features:** - Live dry_run validation on slot selection (no manual trigger needed) - Real-time feedback with checkmarks/errors - Graceful error handling (network failures, validation errors) - Parameterized error messages from getBookingErrorMessage() - SSR-safe with proper disabled states - Professional slate theme matching existing UI - Responsive grid layouts **Integration:** - SlotPickerTile calls validateMoveBooking() API automatically - SlotPickerModal coordinates validation state - CancelBookingModal reads policy fields from BookingDetail - All components ready for integration into BookingDrawer Related: Phase 3, Booking Admin API v1.1, BUILD #23master
parent
13097541f1
commit
6a80607430
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Cancel Booking Modal Component
|
||||
*
|
||||
* Confirmation modal for cancelling bookings with optional reason.
|
||||
* Displays grace period policy information.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import type { BookingDetail } from '@/src/types/bookings';
|
||||
|
||||
interface CancelBookingModalProps {
|
||||
booking: BookingDetail;
|
||||
onConfirm: (reason?: string) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function CancelBookingModal({
|
||||
booking,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: CancelBookingModalProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(reason.trim() || undefined);
|
||||
}
|
||||
|
||||
// Extract grace period from policies if available
|
||||
const graceMinutes = booking.policies?.cancel_grace_minutes || 15;
|
||||
const graceAnchor = booking.policies?.cancel_grace_anchor || 'start';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-lg w-full mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Cancel Booking</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-900 font-semibold mb-2">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
<p className="text-sm text-red-700">
|
||||
Cancelling this booking will free up the slot and notify all attendees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grace Period Info */}
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-xs font-semibold text-blue-900 uppercase mb-1">Policy</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Changes allowed until {graceMinutes} minutes after slot{' '}
|
||||
{graceAnchor === 'end' ? 'ends' : 'starts'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
<div className="bg-slate-50 border-2 border-slate-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase mb-2">Booking Details</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-medium">Court:</span> {booking.slot.court.name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-medium">Time:</span>{' '}
|
||||
{new Date(booking.slot.starts_at).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-medium">Attendees:</span> {booking.attendees.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason (Optional) */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||
Reason for Cancellation (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="E.g., Court maintenance, double booking, player request..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors resize-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">{reason.length} / 500 characters</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-slate-200 text-slate-700 font-semibold rounded-lg hover:bg-slate-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Keep Booking
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-red-600 text-white font-semibold rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Cancelling...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Yes, Cancel Booking'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Slot Picker Modal Component
|
||||
*
|
||||
* Modal for selecting a new slot when moving a booking.
|
||||
* Provides live dry_run validation with visual feedback.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Calendar, Loader2 } from 'lucide-react';
|
||||
import type { BookingSlot } from '@/src/types/bookings';
|
||||
import SlotPickerTile from './SlotPickerTile';
|
||||
|
||||
interface SlotPickerModalProps {
|
||||
bookingId: number;
|
||||
currentSlotId: number;
|
||||
availableSlots: Array<
|
||||
Pick<
|
||||
BookingSlot,
|
||||
'slot_instance_id' | 'starts_at' | 'ends_at' | 'capacity' | 'booked_count' | 'capacity_remaining' | 'status'
|
||||
> & {
|
||||
court: {
|
||||
court_id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
onSelect: (slotInstanceId: number) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function SlotPickerModal({
|
||||
bookingId,
|
||||
currentSlotId,
|
||||
availableSlots,
|
||||
onSelect,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: SlotPickerModalProps) {
|
||||
const [selectedSlotId, setSelectedSlotId] = useState<number | null>(null);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [validationReasons, setValidationReasons] = useState<string[]>([]);
|
||||
|
||||
function handleValidationResult(valid: boolean, reasons?: string[]) {
|
||||
setIsValid(valid);
|
||||
setValidationReasons(reasons || []);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedSlotId && isValid) {
|
||||
onSelect(selectedSlotId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out current slot and group by date
|
||||
const slotsToShow = availableSlots.filter(
|
||||
(slot) => slot.slot_instance_id !== currentSlotId
|
||||
);
|
||||
|
||||
// Group slots by date
|
||||
const slotsByDate = slotsToShow.reduce((acc, slot) => {
|
||||
const date = new Date(slot.starts_at).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
if (!acc[date]) {
|
||||
acc[date] = [];
|
||||
}
|
||||
acc[date].push(slot);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof slotsToShow>);
|
||||
|
||||
// Sort dates
|
||||
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
|
||||
return new Date(a).getTime() - new Date(b).getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="w-6 h-6 text-slate-900" />
|
||||
<h2 className="text-2xl font-bold text-slate-900">Select New Slot</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-blue-900">
|
||||
<span className="font-semibold">Select a slot below.</span> We'll validate it in
|
||||
real-time and show you if it's available for this booking.
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-2">
|
||||
Move window: 14 days · Product type must match · Club must match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Slots Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-600 animate-spin" />
|
||||
</div>
|
||||
) : slotsToShow.length === 0 ? (
|
||||
<div className="bg-slate-50 border-2 border-slate-200 rounded-lg p-12 text-center">
|
||||
<Calendar className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600">No available slots found</p>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedDates.map((date) => (
|
||||
<div key={date}>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3 uppercase tracking-wide">
|
||||
{date}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{slotsByDate[date].map((slot) => (
|
||||
<SlotPickerTile
|
||||
key={slot.slot_instance_id}
|
||||
slot={slot}
|
||||
bookingId={bookingId}
|
||||
selected={selectedSlotId === slot.slot_instance_id}
|
||||
onSelect={setSelectedSlotId}
|
||||
onValidationResult={handleValidationResult}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-8 flex items-center justify-between pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-slate-200 text-slate-700 font-semibold rounded-lg hover:bg-slate-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedSlotId || !isValid || loading}
|
||||
className="px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Moving...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Confirm Move'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Slot Picker Tile Component
|
||||
*
|
||||
* Individual slot tile with dry_run validation feedback.
|
||||
* Shows checkmark/error badges based on move validation.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, X, Loader2 } from 'lucide-react';
|
||||
import type { BookingSlot } from '@/src/types/bookings';
|
||||
import { validateMoveBooking } from '@/src/lib/api/bookings';
|
||||
import { getBookingErrorMessage } from '@/src/types/bookings';
|
||||
import CapacityDisplay from './CapacityDisplay';
|
||||
|
||||
interface SlotPickerTileProps {
|
||||
slot: Pick<
|
||||
BookingSlot,
|
||||
'slot_instance_id' | 'starts_at' | 'ends_at' | 'capacity' | 'booked_count' | 'capacity_remaining' | 'status'
|
||||
> & {
|
||||
court: {
|
||||
court_id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
bookingId: number;
|
||||
selected: boolean;
|
||||
onSelect: (slotInstanceId: number) => void;
|
||||
onValidationResult?: (valid: boolean, reasons?: string[]) => void;
|
||||
}
|
||||
|
||||
type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export default function SlotPickerTile({
|
||||
slot,
|
||||
bookingId,
|
||||
selected,
|
||||
onSelect,
|
||||
onValidationResult,
|
||||
}: SlotPickerTileProps) {
|
||||
const [validationState, setValidationState] = useState<ValidationState>('idle');
|
||||
const [validationReasons, setValidationReasons] = useState<string[]>([]);
|
||||
|
||||
// Run validation when tile is selected
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
runValidation();
|
||||
} else {
|
||||
// Reset validation when deselected
|
||||
setValidationState('idle');
|
||||
setValidationReasons([]);
|
||||
}
|
||||
}, [selected, slot.slot_instance_id]);
|
||||
|
||||
async function runValidation() {
|
||||
setValidationState('validating');
|
||||
setValidationReasons([]);
|
||||
|
||||
const result = await validateMoveBooking(bookingId, {
|
||||
new_slot_instance_id: slot.slot_instance_id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.ok) {
|
||||
setValidationState('valid');
|
||||
setValidationReasons([]);
|
||||
onValidationResult?.(true);
|
||||
} else {
|
||||
setValidationState('invalid');
|
||||
setValidationReasons(result.data.reasons || []);
|
||||
onValidationResult?.(false, result.data.reasons);
|
||||
}
|
||||
} else {
|
||||
// Validation request failed - treat as invalid
|
||||
setValidationState('invalid');
|
||||
const errorMessage = getBookingErrorMessage(result.error);
|
||||
setValidationReasons([errorMessage]);
|
||||
onValidationResult?.(false, [errorMessage]);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
onSelect(slot.slot_instance_id);
|
||||
}
|
||||
|
||||
// Determine tile appearance based on validation state
|
||||
const isDisabled = slot.status !== 'open' || slot.capacity_remaining === 0;
|
||||
const borderColor = selected
|
||||
? validationState === 'valid'
|
||||
? 'border-green-500'
|
||||
: validationState === 'invalid'
|
||||
? 'border-red-500'
|
||||
: 'border-slate-900'
|
||||
: 'border-slate-200';
|
||||
|
||||
const bgColor = selected
|
||||
? validationState === 'valid'
|
||||
? 'bg-green-50'
|
||||
: validationState === 'invalid'
|
||||
? 'bg-red-50'
|
||||
: 'bg-slate-50'
|
||||
: 'bg-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
className={`w-full text-left border-2 rounded-lg p-4 transition-all ${borderColor} ${bgColor} ${
|
||||
isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-slate-300 hover:shadow-sm cursor-pointer'
|
||||
} ${selected ? 'shadow-md' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900 mb-1">{slot.court.name}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{formatTime(slot.starts_at)} - {formatTime(slot.ends_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Validation Badge */}
|
||||
{selected && (
|
||||
<div className="ml-3">
|
||||
{validationState === 'validating' && (
|
||||
<div className="flex items-center justify-center w-6 h-6 bg-slate-200 rounded-full">
|
||||
<Loader2 className="w-4 h-4 text-slate-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{validationState === 'valid' && (
|
||||
<div className="flex items-center justify-center w-6 h-6 bg-green-500 rounded-full">
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{validationState === 'invalid' && (
|
||||
<div className="flex items-center justify-center w-6 h-6 bg-red-500 rounded-full">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Capacity */}
|
||||
<CapacityDisplay slot={slot} variant="compact" />
|
||||
|
||||
{/* Validation Errors */}
|
||||
{selected && validationState === 'invalid' && validationReasons.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200">
|
||||
<ul className="space-y-1">
|
||||
{validationReasons.map((reason, index) => (
|
||||
<li key={index} className="text-xs text-red-700 flex items-start">
|
||||
<span className="mr-1">•</span>
|
||||
<span>{reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{selected && validationState === 'valid' && (
|
||||
<div className="mt-3 pt-3 border-t border-green-200">
|
||||
<p className="text-xs text-green-700 font-medium">✓ Valid - ready to confirm</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue