Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 736535a24c | |||
| 33a1b3ed7a | |||
| 65686c8563 | |||
| 106d31a95e | |||
| 88ba9915c6 | |||
| 26cdd69a49 | |||
| a873369a9b | |||
| d78c859c2b |
@@ -183,6 +183,29 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||||
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||||
|
|
||||||
|
#### 5.12b Dynamic Portal Time Slots (GRO-1793)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
|
||||||
|
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
|
||||||
|
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||||
|
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||||
|
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown |
|
||||||
|
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||||
|
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||||
|
|
||||||
|
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
|
||||||
|
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
|
||||||
|
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
|
||||||
|
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
|
||||||
|
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||||
|
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||||
|
|
||||||
### 5.13 Reports UI
|
### 5.13 Reports UI
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||||
|
|
||||||
const UPCOMING_APPT = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -380,3 +380,201 @@ describe("ConfirmationSection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("StatusBadge", () => {
|
||||||
|
it("renders Confirmed for confirmed status", () => {
|
||||||
|
render(<StatusBadge status="confirmed" />);
|
||||||
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Pending for pending status", () => {
|
||||||
|
render(<StatusBadge status="pending" />);
|
||||||
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Waitlisted for waitlisted status", () => {
|
||||||
|
render(<StatusBadge status="waitlisted" />);
|
||||||
|
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Completed for completed status", () => {
|
||||||
|
render(<StatusBadge status="completed" />);
|
||||||
|
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Cancelled for cancelled status", () => {
|
||||||
|
render(<StatusBadge status="cancelled" />);
|
||||||
|
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to status string for unknown status", () => {
|
||||||
|
render(<StatusBadge status="custom-status" />);
|
||||||
|
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for confirmed status", () => {
|
||||||
|
render(<StatusBadge status="confirmed" />);
|
||||||
|
const badge = screen.getByText("Confirmed").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-green-100");
|
||||||
|
expect(badge?.className).toContain("text-green-700");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for waitlisted status", () => {
|
||||||
|
render(<StatusBadge status="waitlisted" />);
|
||||||
|
const badge = screen.getByText("Waitlisted").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-blue-100");
|
||||||
|
expect(badge?.className).toContain("text-blue-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for pending status", () => {
|
||||||
|
render(<StatusBadge status="pending" />);
|
||||||
|
const badge = screen.getByText("Pending").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-amber-100");
|
||||||
|
expect(badge?.className).toContain("text-amber-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback styling for unknown status", () => {
|
||||||
|
render(<StatusBadge status="unknown" />);
|
||||||
|
const badge = screen.getByText("unknown").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-stone-100");
|
||||||
|
expect(badge?.className).toContain("text-stone-600");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RescheduleFlow dynamic time slots", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const RESCHEDULE_APPT = {
|
||||||
|
id: "appt-r1",
|
||||||
|
petId: "pet-1",
|
||||||
|
petName: "Buddy",
|
||||||
|
groomerId: "groomer-1",
|
||||||
|
groomerName: "Sarah",
|
||||||
|
services: ["Bath & Brush"],
|
||||||
|
serviceId: "service-1",
|
||||||
|
addOns: [],
|
||||||
|
date: "2027-01-01",
|
||||||
|
time: "10:00 AM",
|
||||||
|
duration: 60,
|
||||||
|
price: 50,
|
||||||
|
status: "confirmed" as const,
|
||||||
|
notes: "",
|
||||||
|
customerNotes: "",
|
||||||
|
confirmationStatus: "confirmed" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows loading state while fetching availability", async () => {
|
||||||
|
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays fetched time slots from API", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error state when availability fetch fails", async () => {
|
||||||
|
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no slots message when API returns empty array", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls /api/book/availability with the selected date", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM"] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/book/availability?date=2027-02-20",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-fetches slots when date changes", async () => {
|
||||||
|
vi.mocked(global.fetch)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM"] as string[],
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["11:00 AM", "1:00 PM"] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
|
||||||
|
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,14 +83,34 @@ export function isUpcoming(appt: Appointment): boolean {
|
|||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-600',
|
||||||
waitlisted: 'bg-blue-100 text-blue-700',
|
waitlisted: 'bg-blue-100 text-blue-600',
|
||||||
completed: 'bg-stone-100 text-stone-600',
|
completed: 'bg-stone-100 text-stone-600',
|
||||||
cancelled: 'bg-red-100 text-red-600',
|
cancelled: 'bg-red-100 text-red-600',
|
||||||
'no-show': 'bg-yellow-100 text-yellow-700',
|
'no-show': 'bg-yellow-100 text-yellow-700',
|
||||||
scheduled: 'bg-blue-100 text-blue-700',
|
scheduled: 'bg-blue-100 text-blue-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
pending: 'Pending',
|
||||||
|
waitlisted: 'Waitlisted',
|
||||||
|
completed: 'Completed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
'no-show': 'No-show',
|
||||||
|
scheduled: 'Scheduled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const label = STATUS_LABELS[status] ?? status;
|
||||||
|
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
@@ -298,13 +318,7 @@ function AppointmentCard({
|
|||||||
<span>with {appt.groomerName || 'First Available'}</span>
|
<span>with {appt.groomerName || 'First Available'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<StatusBadge status={appt.status} />
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
STATUS_COLORS[appt.status] || ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{appt.status}
|
|
||||||
</span>
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown size={16} className="text-stone-400" />
|
<ChevronDown size={16} className="text-stone-400" />
|
||||||
) : (
|
) : (
|
||||||
@@ -574,16 +588,26 @@ export function RescheduleFlow({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||||
|
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||||
|
|
||||||
const availableTimes = [
|
useEffect(() => {
|
||||||
'9:00 AM',
|
if (!selectedDate || !sessionId) {
|
||||||
'10:00 AM',
|
setAvailableTimes([]);
|
||||||
'11:00 AM',
|
return;
|
||||||
'1:00 PM',
|
}
|
||||||
'2:00 PM',
|
const params = new URLSearchParams({ date: selectedDate });
|
||||||
'3:00 PM',
|
setSlotsLoading(true);
|
||||||
'4:00 PM',
|
setSlotsError(null);
|
||||||
];
|
fetch(`/api/book/availability?${params.toString()}`, {
|
||||||
|
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||||
|
})
|
||||||
|
.then((r) => r.json() as Promise<string[]>)
|
||||||
|
.then(setAvailableTimes)
|
||||||
|
.catch(() => setSlotsError('Failed to load time slots'))
|
||||||
|
.finally(() => setSlotsLoading(false));
|
||||||
|
}, [selectedDate, sessionId]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!selectedDate || !selectedTime) return;
|
if (!selectedDate || !selectedTime) return;
|
||||||
@@ -655,6 +679,7 @@ export function RescheduleFlow({
|
|||||||
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="Select date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
min={new Date().toISOString().split('T')[0]}
|
min={new Date().toISOString().split('T')[0]}
|
||||||
@@ -662,7 +687,12 @@ export function RescheduleFlow({
|
|||||||
/>
|
/>
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
{availableTimes.map((time) => (
|
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability…</p>}
|
||||||
|
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
|
||||||
|
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
|
||||||
|
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
|
||||||
|
)}
|
||||||
|
{!slotsLoading && availableTimes.map((time) => (
|
||||||
<button
|
<button
|
||||||
key={time}
|
key={time}
|
||||||
onClick={() => setSelectedTime(time)}
|
onClick={() => setSelectedTime(time)}
|
||||||
@@ -729,16 +759,26 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||||
|
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||||
|
|
||||||
const availableTimes = [
|
useEffect(() => {
|
||||||
'9:00 AM',
|
if (!selectedDate || !sessionId) {
|
||||||
'10:00 AM',
|
setAvailableTimes([]);
|
||||||
'11:00 AM',
|
return;
|
||||||
'1:00 PM',
|
}
|
||||||
'2:00 PM',
|
const params = new URLSearchParams({ date: selectedDate });
|
||||||
'3:00 PM',
|
setSlotsLoading(true);
|
||||||
'4:00 PM',
|
setSlotsError(null);
|
||||||
];
|
fetch(`/api/book/availability?${params.toString()}`, {
|
||||||
|
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||||
|
})
|
||||||
|
.then((r) => r.json() as Promise<string[]>)
|
||||||
|
.then(setAvailableTimes)
|
||||||
|
.catch(() => setSlotsError('Failed to load time slots'))
|
||||||
|
.finally(() => setSlotsLoading(false));
|
||||||
|
}, [selectedDate, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -1059,6 +1099,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
|
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="Select date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
min={new Date().toISOString().split('T')[0]}
|
min={new Date().toISOString().split('T')[0]}
|
||||||
@@ -1066,7 +1107,12 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
/>
|
/>
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
{availableTimes.map((time) => (
|
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability…</p>}
|
||||||
|
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
|
||||||
|
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
|
||||||
|
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
|
||||||
|
)}
|
||||||
|
{!slotsLoading && availableTimes.map((time) => (
|
||||||
<button
|
<button
|
||||||
key={time}
|
key={time}
|
||||||
onClick={() => setSelectedTime(time)}
|
onClick={() => setSelectedTime(time)}
|
||||||
|
|||||||
Reference in New Issue
Block a user