refactor: update home page UI to v2 patterns and introduce availability heatmap component
This commit is contained in:
113
apps/frontend/src/components/ui/availability-heatmap.tsx
Normal file
113
apps/frontend/src/components/ui/availability-heatmap.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user