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>
This commit is contained in:
Barcode Betty
2026-05-26 12:00:55 +00:00
parent b630b40c92
commit 344a32e3e4
6 changed files with 187 additions and 44 deletions
+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>
); );