diff --git a/app/(en)/(content)/dive-log/page.tsx b/app/(en)/(content)/dive-log/page.tsx index 8dd6b1b..b5345af 100644 --- a/app/(en)/(content)/dive-log/page.tsx +++ b/app/(en)/(content)/dive-log/page.tsx @@ -9,5 +9,30 @@ export const metadata = createMetadata({ }); export default function DiveLogPage() { - return ; + return ( + <> + + {/* Without JS the client component sits on its loading state forever + — say so and offer a real next step instead (issue #209). */} + + + ); } diff --git a/app/global-error.tsx b/app/global-error.tsx index f97db61..9e552f8 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -39,6 +39,21 @@ export default function GlobalError({ > Try again + {/* Escape route for persistent failures — a retry that keeps + failing shouldn't strand the visitor on a dead-end page. */} +

+ {/* Plain on purpose: this boundary renders when the root + layout — and possibly the router context needs — + has already failed. A full navigation re-bootstraps the + app, which is the recovery the escape route exists for. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + If it keeps happening, head back to the homepage + +

diff --git a/components/booking-widget.tsx b/components/booking-widget.tsx index 73c0f53..c932de7 100644 --- a/components/booking-widget.tsx +++ b/components/booking-widget.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { BubbleLoader } from "@/components/bubble-loader"; +import { StatePanel } from "@/components/state-panel"; import { buttonVariants } from "@/components/ui/button"; import { trackLinkClick } from "@/lib/analytics"; import { cn } from "@/lib/utils"; @@ -157,15 +158,22 @@ export function BookingWidget({ item }: BookingWidgetProps) { return (
{status === "error" && ( -
-

- Booking isn't loading -

-

- Open our secure booking page directly - {itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another - way — we'll get you booked. -

+ // role="alert": the "Loading availability" status region unmounts + // when the widget fails, so nothing would announce the failure — + // the panel must speak for itself (issue #209). + + Open our secure booking page directly + {itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another + way — we'll get you booked. + + } + > -
+ )} {unknown && ( diff --git a/components/partners/accommodations-section.tsx b/components/partners/accommodations-section.tsx index 7d8f632..83d3b79 100644 --- a/components/partners/accommodations-section.tsx +++ b/components/partners/accommodations-section.tsx @@ -5,6 +5,7 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation"; import Link from "next/link"; import { ExternalLink, MapPin, Search, X } from "lucide-react"; import { Pill } from "@/components/ui/pill"; +import { StatePanel } from "@/components/state-panel"; import { buttonVariants } from "@/components/ui/button"; import { trackLinkClick } from "@/lib/analytics"; import { cn } from "@/lib/utils"; @@ -222,9 +223,12 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP
)} - {/* Results header */} + {/* Results header — the count is a polite live region so a filter + change to zero results is announced while focus stays on the + filter. aria-atomic reads the whole new count, not just the + changed digit. */}
-

+

{filtered.length} accommodation{filtered.length !== 1 ? "s" : ""}

{hasActiveFilters && ( @@ -237,18 +241,22 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP )}
- {/* No results */} + {/* No results — shared StatePanel; no live role of its own, the + live result-count heading above already announces the change + (issue #209). */} {filtered.length === 0 && ( -
-

No accommodations match your filters.

-

Try clearing a filter or searching for a different term.

+ -
+ )} {/* Hotels */} diff --git a/components/state-panel.tsx b/components/state-panel.tsx index 4fcaf0e..6bb5382 100644 --- a/components/state-panel.tsx +++ b/components/state-panel.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"; */ export function StatePanel({ title, + titleClassName, description, leading, role, @@ -23,6 +24,8 @@ export function StatePanel({ className, }: { title: string; + /** Override for prominent failures — default is the quiet text-sm title. */ + titleClassName?: string; description?: ReactNode; /** Visual shown above the title — BubbleLoader, icon, etc. */ leading?: ReactNode; @@ -40,7 +43,7 @@ export function StatePanel({ )} > {leading} -

{title}

+

{title}

{description && (

{description}

)} diff --git a/tests/integration/accommodations-section.test.tsx b/tests/integration/accommodations-section.test.tsx index 17d1b88..a7b4fac 100644 --- a/tests/integration/accommodations-section.test.tsx +++ b/tests/integration/accommodations-section.test.tsx @@ -67,3 +67,24 @@ it("announces filter pills as toggles, not as current navigation items", async ( expect(screen.getByRole("heading", { name: "1 accommodation" })).toBeVisible(); expect(screen.queryByRole("heading", { name: "Bay Cottage" })).toBeNull(); }); + +// Issue #209: a filtered-empty result must explain what happened and offer +// the way back — never a silent zero-result list. +it("explains an empty filter result and offers a way back", async () => { + render(); + await userEvent.type( + screen.getByRole("searchbox", { name: "Search accommodations" }), + "zzz-no-match" + ); + // The always-mounted result count is a polite live region, so the zero + // state is announced while focus stays on the filter control. + const count = screen.getByRole("heading", { name: "0 accommodations" }); + expect(count).toHaveAttribute("aria-live", "polite"); + expect(count).toHaveAttribute("aria-atomic", "true"); + const panel = screen.getByText("No accommodations match your filters."); + expect(panel).toBeVisible(); + // The recovery action actually restores the list. + await userEvent.click(screen.getByRole("button", { name: "Clear all filters" })); + expect(screen.getByRole("heading", { name: "2 accommodations" })).toBeVisible(); + expect(screen.queryByText("No accommodations match your filters.")).toBeNull(); +}); diff --git a/tests/integration/booking-widget.test.tsx b/tests/integration/booking-widget.test.tsx index d0ba751..05d6d60 100644 --- a/tests/integration/booking-widget.test.tsx +++ b/tests/integration/booking-widget.test.tsx @@ -110,6 +110,18 @@ it("keeps the error fallback free of the loading treatment", () => { expect(document.querySelector(".animate-bubble-rise")).toBeNull(); }); +it("announces the failure — the loading status unmounts, so the panel speaks for itself (#209)", () => { + render(); + fireEvent.error(script()); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Booking isn't loading"); + expect(alert).toHaveTextContent(/secure booking page/i); + // The recovery paths stay inside the announced region. + expect(alert).toContainElement( + screen.getByRole("link", { name: /continue to secure booking system/i }) + ); +}); + it("reuses the script on navigation and stops polling after unmount", () => { const existing = document.createElement("script"); existing.id = "checkfront-interface-script";