Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion app/(en)/(content)/dive-log/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,30 @@ export const metadata = createMetadata({
});

export default function DiveLogPage() {
return <DiveLogClient />;
return (
<>
<DiveLogClient />
{/* Without JS the client component sits on its loading state forever
— say so and offer a real next step instead (issue #209). */}
<noscript>
<p className="mx-auto max-w-md px-4 pb-10 text-center text-sm text-muted-foreground">
Recent dive activity needs JavaScript to load. You can still{" "}
<a
href="/dive-sites"
className="font-medium text-primary underline underline-offset-2 hover:underline"
>
browse our dive sites
</a>{" "}
or{" "}
<a
href="/contact"
className="font-medium text-primary underline underline-offset-2 hover:underline"
>
ask us about a recent trip
</a>
.
</p>
</noscript>
</>
);
}
15 changes: 15 additions & 0 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ export default function GlobalError({
>
Try again
</button>
{/* Escape route for persistent failures — a retry that keeps
failing shouldn't strand the visitor on a dead-end page. */}
<p className="mt-4 text-sm">
{/* Plain <a> on purpose: this boundary renders when the root
layout — and possibly the router context <Link> 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 */}
<a
href="/"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="font-medium text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
If it keeps happening, head back to the homepage
</a>
</p>
</main>
</body>
</html>
Expand Down
28 changes: 18 additions & 10 deletions components/booking-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -157,15 +158,22 @@ export function BookingWidget({ item }: BookingWidgetProps) {
return (
<div className="w-full">
{status === "error" && (
<div className="rounded-lg border border-border/40 bg-muted/20 p-10 text-center">
<p className="text-lg font-semibold text-foreground">
Booking isn&apos;t loading
</p>
<p className="mt-2 text-sm text-muted-foreground">
Open our secure booking page directly
{itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another
way — we&apos;ll get you booked.
</p>
// 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).
<StatePanel
role="alert"
className="p-10"
title="Booking isn't loading"
titleClassName="text-lg font-semibold text-foreground"
description={
<>
Open our secure booking page directly
{itemId ? ` for ${ITEM_NAMES[itemId]}` : ""}, or reach us another
way — we&apos;ll get you booked.
</>
}
>
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
<a
href={fallbackUrl}
Expand Down Expand Up @@ -207,7 +215,7 @@ export function BookingWidget({ item }: BookingWidgetProps) {
Contact us instead
</a>
</div>
</div>
</StatePanel>
)}

{unknown && (
Expand Down
22 changes: 15 additions & 7 deletions components/partners/accommodations-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -222,9 +223,12 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP
</div>
)}

{/* 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. */}
<div className="mt-8 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
<h3 className="text-sm font-semibold text-foreground" aria-live="polite" aria-atomic="true">
{filtered.length} accommodation{filtered.length !== 1 ? "s" : ""}
</h3>
{hasActiveFilters && (
Expand All @@ -237,18 +241,22 @@ export function AccommodationsSection({ accommodations }: AccommodationsSectionP
)}
</div>

{/* 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 && (
<div className="mt-6 rounded-lg border border-border/40 bg-muted/20 p-8 text-center">
<p className="text-sm font-medium text-foreground">No accommodations match your filters.</p>
<p className="mt-1 text-sm text-muted-foreground">Try clearing a filter or searching for a different term.</p>
<StatePanel
className="mt-6"
title="No accommodations match your filters."
description="Try clearing a filter or searching for a different term."
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
onClick={clearAll}
className="mt-4 rounded-md px-2 py-1 text-sm font-medium text-primary pressable hover:underline focus-ring"
>
Clear all filters
</button>
</div>
</StatePanel>
)}

{/* Hotels */}
Expand Down
5 changes: 4 additions & 1 deletion components/state-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import { cn } from "@/lib/utils";
*/
export function StatePanel({
title,
titleClassName,
description,
leading,
role,
children,
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;
Expand All @@ -40,7 +43,7 @@ export function StatePanel({
)}
>
{leading}
<p className="text-sm font-medium text-foreground">{title}</p>
<p className={cn("text-sm font-medium text-foreground", titleClassName)}>{title}</p>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/accommodations-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AccommodationsSection accommodations={FIXTURE} />);
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();
});
12 changes: 12 additions & 0 deletions tests/integration/booking-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<BookingWidget />);
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";
Expand Down
Loading