114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import React from 'react';
|
||
|
||
// Types for the component
|
||
type Slot = {
|
||
startTime: string;
|
||
endTime: string;
|
||
};
|
||
|
||
type Court = {
|
||
courtId: string;
|
||
courtName: string;
|
||
sport: { id: string; name: string; slug: string };
|
||
slotDurationMinutes: number;
|
||
availableSlots: Slot[];
|
||
};
|
||
|
||
type SelectedSlot = {
|
||
courtId: string;
|
||
startTime: string;
|
||
} | null;
|
||
|
||
type AvailabilityHeatMapProps = {
|
||
courts: Court[];
|
||
selectedSlot: SelectedSlot;
|
||
onSelectSlot: (payload: {
|
||
courtId: string;
|
||
courtName: string;
|
||
sportId: string;
|
||
sportName: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
price?: { min: number; max: number };
|
||
}) => void;
|
||
};
|
||
|
||
/**
|
||
* AvailabilityHeatMap
|
||
*
|
||
* A visual heat‑map style component that displays each court as a card and colours the
|
||
* slot buttons according to how many slots are available for that court. The colour scale
|
||
* goes from green (few slots) to red (many slots), providing an immediate visual cue of
|
||
* density – the signature element requested for the bookings panel.
|
||
*/
|
||
export default function AvailabilityHeatMap({
|
||
courts,
|
||
selectedSlot,
|
||
onSelectSlot,
|
||
}: AvailabilityHeatMapProps) {
|
||
// Determine the maximum number of slots among all courts to normalise the heat scale.
|
||
const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length));
|
||
|
||
// Convert a slot count into an HSL colour ranging from green (low) to red (high).
|
||
const slotCountToHue = (count: number) => {
|
||
const ratio = count / maxSlots; // 0 => green, 1 => red
|
||
const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red)
|
||
return `hsl(${hue}, 70%, 55%)`;
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-4">
|
||
{courts.map((court) => {
|
||
const bg = slotCountToHue(court.availableSlots.length);
|
||
return (
|
||
<div
|
||
key={court.courtId}
|
||
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="font-semibold text-foreground">{court.courtName}</p>
|
||
<span className="text-xs text-muted-foreground">
|
||
{court.availableSlots.length} horarios
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{court.availableSlots.map((slot) => {
|
||
const isSelected =
|
||
selectedSlot?.courtId === court.courtId &&
|
||
selectedSlot?.startTime === slot.startTime;
|
||
return (
|
||
<button
|
||
key={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,
|
||
})
|
||
}
|
||
className={`h-10 rounded-xl border text-sm font-medium transition-all ${
|
||
isSelected
|
||
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||
: 'border-border/70 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
|
||
}`}
|
||
style={{
|
||
backgroundColor: isSelected ? undefined : bg,
|
||
opacity: isSelected ? 1 : 0.8,
|
||
}}
|
||
>
|
||
{slot.startTime}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|