arbret/frontend/app/booking/page.tsx
counterweight 6ff3c0a133
Extract duplicate date formatting functions to shared utilities
- Created frontend/app/utils/date.ts with formatDate, formatTime, formatDateTime, formatDisplayDate
- Created frontend/e2e/helpers/date.ts with formatDateLocal, getTomorrowDateStr
- Updated all frontend pages and e2e tests to use shared utilities
- Removed duplicate date formatting code from 6 files
2025-12-21 17:48:17 +01:00

465 lines
13 KiB
TypeScript

"use client";
import React from "react";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import constants from "../../../shared/constants.json";
import { formatDate, formatTime } from "../utils/date";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
// Get date range for booking (tomorrow to +30 days)
function getBookableDates(): Date[] {
const dates: Date[] = [];
const today = new Date();
for (let i = minAdvanceDays; i <= maxAdvanceDays; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
dates.push(d);
}
return dates;
}
const styles: Record<string, React.CSSProperties> = {
main: {
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
display: "flex",
flexDirection: "column",
},
loader: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
},
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
pageTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.75rem",
fontWeight: 600,
color: "#fff",
marginBottom: "0.5rem",
},
pageSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.9rem",
marginBottom: "1.5rem",
},
successBanner: {
background: "rgba(34, 197, 94, 0.15)",
border: "1px solid rgba(34, 197, 94, 0.3)",
color: "#4ade80",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
},
errorBanner: {
background: "rgba(239, 68, 68, 0.15)",
border: "1px solid rgba(239, 68, 68, 0.3)",
color: "#f87171",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
dateGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
dateButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "10px",
cursor: "pointer",
minWidth: "90px",
textAlign: "center" as const,
transition: "all 0.2s",
},
dateButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
borderColor: "#a78bfa",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
slotGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
slotButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.6rem 1.25rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
fontSize: "0.9rem",
transition: "all 0.2s",
},
slotButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
borderColor: "#a78bfa",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
padding: "1rem 0",
},
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
marginBottom: "1rem",
},
inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
display: "block",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem",
marginBottom: "0.5rem",
},
textarea: {
fontFamily: "'DM Sans', system-ui, sans-serif",
width: "100%",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
fontSize: "0.875rem",
minHeight: "80px",
resize: "vertical" as const,
},
charCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "right" as const,
marginTop: "0.25rem",
},
charCountWarning: {
color: "#f87171",
},
buttonRow: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.75rem",
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.2s",
},
bookButtonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
};
export default function BookingPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT,
fallbackRedirect: "/",
});
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
const [note, setNote] = useState("");
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
const [isBooking, setIsBooking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const dates = getBookableDates();
const fetchSlots = useCallback(async (date: Date) => {
setIsLoadingSlots(true);
setError(null);
setAvailableSlots([]);
setSelectedSlot(null);
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
setAvailableSlots(data.slots);
} catch (err) {
console.error("Failed to fetch slots:", err);
setError("Failed to load available slots");
} finally {
setIsLoadingSlots(false);
}
}, []);
useEffect(() => {
if (selectedDate && user && isAuthorized) {
fetchSlots(selectedDate);
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
const handleDateSelect = (date: Date) => {
setSelectedDate(date);
setSuccessMessage(null);
};
const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot);
setNote("");
setError(null);
};
const handleBook = async () => {
if (!selectedSlot) return;
setIsBooking(true);
setError(null);
try {
const appointment = await api.post<AppointmentResponse>("/api/booking", {
slot_start: selectedSlot.start_time,
note: note || null,
});
setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`);
setSelectedSlot(null);
setNote("");
// Refresh slots to show the booked one is gone
if (selectedDate) {
await fetchSlots(selectedDate);
}
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError("Failed to book appointment");
}
} finally {
setIsBooking(false);
}
};
const cancelSlotSelection = () => {
setSelectedSlot(null);
setNote("");
setError(null);
};
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<Header currentPage="booking" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>Book an Appointment</h1>
<p style={styles.pageSubtitle}>
Select a date to see available {slotDurationMinutes}-minute slots
</p>
{successMessage && (
<div style={styles.successBanner}>{successMessage}</div>
)}
{error && (
<div style={styles.errorBanner}>{error}</div>
)}
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
return (
<button
key={formatDate(date)}
onClick={() => handleDateSelect(date)}
style={{
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
}}
>
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
</button>
);
})}
</div>
</div>
{/* Available Slots */}
{selectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for {selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric"
})}
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={slot.start_time}
onClick={() => handleSlotSelect(slot)}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
</button>
);
})}
</div>
)}
</div>
)}
{/* Booking Form */}
{selectedSlot && (
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
<p style={styles.confirmTime}>
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</p>
<div>
<label style={styles.inputLabel}>
Note (optional, max {noteMaxLength} chars)
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..."
style={styles.textarea}
/>
<div style={{
...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
}}>
{note.length}/{noteMaxLength}
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
disabled={isBooking}
style={{
...styles.bookButton,
...(isBooking ? styles.bookButtonDisabled : {}),
}}
>
{isBooking ? "Booking..." : "Book Appointment"}
</button>
<button
onClick={cancelSlotSelection}
disabled={isBooking}
style={styles.cancelButton}
>
Cancel
</button>
</div>
</div>
)}
</div>
</main>
);
}