Compare commits

..

1 Commits

Author SHA1 Message Date
Barcode Betty 344a32e3e4 feat(GRO-1792): add recovery paths to booking error and cancellation pages
CI / Test (pull_request) Failing after 18s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add "Start a new booking" button to BookingError linking to /admin/book
- Add "Book again" button to BookingCancelled linking to /admin/book
- Add business contact info section to BookingError (from BUSINESS_CONTACT_INFO constant)
- Replace hardcoded colors with CSS variables (--color-error, --color-cancelled, etc.)
- Add page-level string constants to eliminate hardcoded strings
- Add unit tests for both pages (9 tests passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:00:55 +00:00
9 changed files with 207 additions and 246 deletions
-12
View File
@@ -183,18 +183,6 @@ 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.13 Reports UI ### 5.13 Reports UI
| # | Scenario | Steps | Expected | | # | Scenario | Steps | Expected |
-138
View File
@@ -380,141 +380,3 @@ describe("ConfirmationSection", () => {
}); });
}); });
}); });
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();
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingCancelledPage } from "../pages/BookingCancelled.tsx";
describe("BookingCancelledPage", () => {
it("renders the cancelled heading", () => {
render(<BookingCancelledPage />);
expect(screen.getByRole("heading", { name: /Appointment Cancelled/i })).toBeInTheDocument();
});
it("renders the cancelled body text", () => {
render(<BookingCancelledPage />);
expect(screen.getByText(/Your appointment has been cancelled/i)).toBeInTheDocument();
});
it("has a Book again link pointing to /admin/book", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Book again/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingErrorPage } from "../pages/BookingError.tsx";
import { BUSINESS_CONTACT_INFO } from "../lib/contact.ts";
describe("BookingErrorPage", () => {
it("renders the error heading", () => {
render(<BookingErrorPage />);
expect(screen.getByRole("heading", { name: /Link Invalid or Expired/i })).toBeInTheDocument();
});
it("renders the error body text", () => {
render(<BookingErrorPage />);
expect(screen.getByText(/This confirmation link is invalid/i)).toBeInTheDocument();
});
it("has a Start a new booking link pointing to /admin/book", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Start a new booking/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
it("displays business contact phone", () => {
render(<BookingErrorPage />);
expect(screen.getByText(BUSINESS_CONTACT_INFO.phone)).toBeInTheDocument();
});
it("displays business contact email", () => {
render(<BookingErrorPage />);
expect(screen.getByText(BUSINESS_CONTACT_INFO.email)).toBeInTheDocument();
});
});
+13
View File
@@ -8,6 +8,19 @@
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000); --color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff); --color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff); --color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
/* Semantic / booking page tokens */
--color-error: #dc2626;
--color-error-dark: #b91c1c;
--color-error-bg: #fef2f2;
--color-cancelled: #ea580c;
--color-cancelled-dark: #c2410c;
--color-cancelled-bg: #fff7ed;
--color-success: #16a34a;
--color-success-dark: #15803d;
--color-success-bg: #f0fdf4;
--color-text-secondary: #4b5563;
--color-surface: #fff;
} }
*, *::before, *::after { *, *::before, *::after {
+7
View File
@@ -0,0 +1,7 @@
// Business contact information — update values to reflect actual business details.
// Used on error/cancellation pages to help customers reach the business.
export const BUSINESS_CONTACT_INFO = {
phone: "(555) 000-1234",
email: "hello@groombook.example.com",
address: "123 Main St, Anytown, USA",
} as const;
+46 -22
View File
@@ -1,3 +1,10 @@
const STRINGS = {
heading: "Appointment Cancelled",
body: "Your appointment has been cancelled. If this was a mistake or you'd like to rebook, please contact us.",
bookAgain: "Book again",
backToPortal: "Back to Portal",
} as const;
export function BookingCancelledPage() { export function BookingCancelledPage() {
return ( return (
<div <div
@@ -7,12 +14,12 @@ export function BookingCancelledPage() {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontFamily: "system-ui, sans-serif", fontFamily: "system-ui, sans-serif",
background: "#fff7ed", background: "var(--color-cancelled-bg)",
}} }}
> >
<div <div
style={{ style={{
background: "#fff", background: "var(--color-surface)",
borderRadius: 12, borderRadius: 12,
padding: "2.5rem 3rem", padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)", boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +28,45 @@ export function BookingCancelledPage() {
}} }}
> >
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div> <div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}> <h1 style={{ color: "var(--color-cancelled-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Cancelled {STRINGS.heading}
</h1> </h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}> <p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
Your appointment has been cancelled. If this was a mistake or you'd {STRINGS.body}
like to rebook, please contact us.
</p> </p>
<a
href="/" <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
style={{ <a
display: "inline-block", href="/admin/book"
padding: "0.6rem 1.5rem", style={{
background: "#ea580c", display: "inline-block",
color: "#fff", padding: "0.6rem 1.5rem",
borderRadius: 6, background: "var(--color-primary)",
textDecoration: "none", color: "#fff",
fontWeight: 600, borderRadius: 6,
fontSize: 14, textDecoration: "none",
}} fontWeight: 600,
> fontSize: 14,
Back to Portal }}
</a> >
{STRINGS.bookAgain}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-cancelled)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
</div> </div>
</div> </div>
); );
+56 -22
View File
@@ -1,3 +1,13 @@
import { BUSINESS_CONTACT_INFO } from "../lib/contact";
const STRINGS = {
heading: "Link Invalid or Expired",
body: "This confirmation link is invalid, has already been used, or your appointment has already passed. Please contact us if you need help.",
newBooking: "Start a new booking",
backToPortal: "Back to Portal",
contactLabel: "Need help?",
} as const;
export function BookingErrorPage() { export function BookingErrorPage() {
return ( return (
<div <div
@@ -7,12 +17,12 @@ export function BookingErrorPage() {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontFamily: "system-ui, sans-serif", fontFamily: "system-ui, sans-serif",
background: "#fef2f2", background: "var(--color-error-bg)",
}} }}
> >
<div <div
style={{ style={{
background: "#fff", background: "var(--color-surface)",
borderRadius: 12, borderRadius: 12,
padding: "2.5rem 3rem", padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)", boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +31,52 @@ export function BookingErrorPage() {
}} }}
> >
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div> <div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}> <h1 style={{ color: "var(--color-error-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
Link Invalid or Expired {STRINGS.heading}
</h1> </h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}> <p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
This confirmation link is invalid, has already been used, or your {STRINGS.body}
appointment has already passed. Please contact us if you need help.
</p> </p>
<a
href="/" <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
style={{ <a
display: "inline-block", href="/admin/book"
padding: "0.6rem 1.5rem", style={{
background: "#dc2626", display: "inline-block",
color: "#fff", padding: "0.6rem 1.5rem",
borderRadius: 6, background: "var(--color-primary)",
textDecoration: "none", color: "#fff",
fontWeight: 600, borderRadius: 6,
fontSize: 14, textDecoration: "none",
}} fontWeight: 600,
> fontSize: 14,
Back to Portal }}
</a> >
{STRINGS.newBooking}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-error)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
<div style={{ marginTop: "1.5rem", paddingTop: "1rem", borderTop: "1px solid #e5e7eb", fontSize: 13, color: "var(--color-text-secondary)" }}>
<p style={{ margin: "0 0 0.25rem", fontWeight: 600 }}>{STRINGS.contactLabel}</p>
<p style={{ margin: 0 }}>
{BUSINESS_CONTACT_INFO.phone} · {BUSINESS_CONTACT_INFO.email}
</p>
</div>
</div> </div>
</div> </div>
); );
+20 -52
View File
@@ -573,26 +573,16 @@ 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[]>([]);
useEffect(() => { const availableTimes = [
if (!selectedDate || !sessionId) { '9:00 AM',
setAvailableTimes([]); '10:00 AM',
return; '11:00 AM',
} '1:00 PM',
const params = new URLSearchParams({ date: selectedDate }); '2:00 PM',
setSlotsLoading(true); '3:00 PM',
setSlotsError(null); '4:00 PM',
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;
@@ -664,7 +654,6 @@ 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]}
@@ -672,12 +661,7 @@ 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">
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>} {availableTimes.map((time) => (
{!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)}
@@ -739,26 +723,16 @@ 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[]>([]);
useEffect(() => { const availableTimes = [
if (!selectedDate || !sessionId) { '9:00 AM',
setAvailableTimes([]); '10:00 AM',
return; '11:00 AM',
} '1:00 PM',
const params = new URLSearchParams({ date: selectedDate }); '2:00 PM',
setSlotsLoading(true); '3:00 PM',
setSlotsError(null); '4:00 PM',
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 () => {
@@ -1074,7 +1048,6 @@ 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]}
@@ -1082,12 +1055,7 @@ 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">
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>} {availableTimes.map((time) => (
{!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)}