feat(bookings): add dry_run slot picker with live validation
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 #23
master
Guillermo Pages 1 month ago
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…
Cancel
Save