feat(public-booking): refactor court selection and available slots components for improved usability
This commit is contained in:
@@ -2,13 +2,6 @@ import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
Stepper,
|
Stepper,
|
||||||
StepperIndicator,
|
StepperIndicator,
|
||||||
@@ -716,15 +709,10 @@ function PublicBookingMobile(props: BookingShellProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{props.courts.length > 0 ? (
|
{props.courts.length > 0 ? (
|
||||||
<MobileSelect
|
<MobileCourtPicker
|
||||||
label="Cancha"
|
courts={props.courts}
|
||||||
value={selectedCourt?.courtId ?? ''}
|
selectedCourtId={selectedCourt?.courtId}
|
||||||
placeholder="Seleccioná"
|
onSelectCourt={props.onSelectCourt}
|
||||||
onValueChange={props.onSelectCourt}
|
|
||||||
options={props.courts.map((court) => ({
|
|
||||||
value: court.courtId,
|
|
||||||
label: court.courtName,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!props.isLoading && (
|
!props.isLoading && (
|
||||||
@@ -772,16 +760,12 @@ function PublicBookingMobile(props: BookingShellProps) {
|
|||||||
<Panel className="overflow-hidden">
|
<Panel className="overflow-hidden">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<CourtHeader court={selectedCourt} compact />
|
<CourtHeader court={selectedCourt} compact />
|
||||||
<div className="mt-3">
|
|
||||||
<Legend />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-white/10 px-0 py-3">
|
<div className="border-t border-white/10 p-3">
|
||||||
<BookingTimeline
|
<MobileAvailableSlots
|
||||||
court={selectedCourt}
|
court={selectedCourt}
|
||||||
selectedSlot={props.selectedSlot}
|
selectedSlot={props.selectedSlot}
|
||||||
onSelectSlot={props.onSelectSlot}
|
onSelectSlot={props.onSelectSlot}
|
||||||
compact
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pb-3">
|
<div className="px-3 pb-3">
|
||||||
@@ -890,34 +874,118 @@ function MobileSportSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileSelect({
|
function MobileCourtPicker({
|
||||||
label,
|
courts,
|
||||||
value,
|
selectedCourtId,
|
||||||
placeholder,
|
onSelectCourt,
|
||||||
options,
|
|
||||||
onValueChange,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
courts: PublicAvailabilityCourt[];
|
||||||
value: string;
|
selectedCourtId?: string;
|
||||||
placeholder: string;
|
onSelectCourt: (courtId: string) => void;
|
||||||
options: { value: string; label: string }[];
|
|
||||||
onValueChange: (value: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center border-b border-white/10 bg-white/[0.025] px-3 py-2 last:border-b-0">
|
<div className="border-b border-white/10 bg-white/[0.025] px-3 py-3 last:border-b-0">
|
||||||
<span className="text-xs text-white/66">{label}</span>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Select value={value} onValueChange={onValueChange}>
|
{courts.map((court) => {
|
||||||
<SelectTrigger className="h-8 border-0 bg-transparent px-0 text-xs text-white shadow-none">
|
const isSelected = selectedCourtId === court.courtId;
|
||||||
<SelectValue placeholder={placeholder} />
|
|
||||||
</SelectTrigger>
|
return (
|
||||||
<SelectContent>
|
<button
|
||||||
{options.map((option) => (
|
key={court.courtId}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
type="button"
|
||||||
{option.label}
|
onClick={() => onSelectCourt(court.courtId)}
|
||||||
</SelectItem>
|
aria-pressed={isSelected}
|
||||||
))}
|
className={`flex min-h-16 items-start gap-2 rounded-md border p-3 text-left transition ${
|
||||||
</SelectContent>
|
isSelected
|
||||||
</Select>
|
? 'border-emerald-400 bg-emerald-500/20 text-white shadow-[inset_0_0_18px_rgba(16,185,129,0.12)]'
|
||||||
|
: 'border-white/10 bg-white/[0.045] text-white/72 active:border-white/25 active:bg-white/[0.075]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-300 bg-emerald-400 text-[#061019]'
|
||||||
|
: 'border-white/18 text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold">{court.courtName}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-white/52">
|
||||||
|
{court.sport.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAvailableSlots({
|
||||||
|
court,
|
||||||
|
selectedSlot,
|
||||||
|
onSelectSlot,
|
||||||
|
}: {
|
||||||
|
court: PublicAvailabilityCourt;
|
||||||
|
selectedSlot: SelectedSlot | null;
|
||||||
|
onSelectSlot: (slot: SelectedSlot) => void;
|
||||||
|
}) {
|
||||||
|
const slots = court.availableSlots;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Horarios disponibles</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-white/58">
|
||||||
|
Turnos de {court.slotDurationMinutes} minutos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs font-medium text-emerald-300/90">
|
||||||
|
{slots.length} {slots.length === 1 ? 'turno' : 'turnos'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slots.length > 0 ? (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const selected = isSlotSelected(slot, court.courtId, selectedSlot);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${court.courtId}-${slot.startTime}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSelectSlot({
|
||||||
|
courtId: court.courtId,
|
||||||
|
courtName: court.courtName,
|
||||||
|
sportId: court.sport.id,
|
||||||
|
sportName: court.sport.name,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`flex h-12 items-center justify-center gap-1.5 rounded-md border text-sm font-bold transition ${
|
||||||
|
selected
|
||||||
|
? 'border-emerald-300 bg-emerald-400 text-[#061019] shadow-lg shadow-emerald-500/20'
|
||||||
|
: 'border-emerald-500/45 bg-emerald-500/12 text-white active:bg-emerald-500/24'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-4" />}
|
||||||
|
{slot.startTime}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
|
||||||
|
No hay horarios disponibles para esta cancha.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user