From 3e78c2d4abc92428a8d2ba9c75904c1a3e71ca1c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 23:06:36 +0200 Subject: [PATCH] fix(F17 ext): apply DetailNotFound to clients/yachts/companies/berths Refactored the interest-detail 404 pattern into a reusable `` component and applied it to the four other entity detail pages. Pre-fix, navigating to a wrong-port or stale entity URL silently rendered the layout shell with empty tabs on: - /[portSlug]/clients/[id] - /[portSlug]/yachts/[id] - /[portSlug]/companies/[id] - /[portSlug]/berths/[id] All four now route a 404/403 response into an explicit " not found" / "No access" EmptyState with a back-to-list CTA, and the TanStack Query retry policy short-circuits 404/403s so the empty state appears immediately. 1373/1373 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/berths/berth-detail.tsx | 25 +++++++++- src/components/clients/client-detail.tsx | 24 +++++++++- src/components/companies/company-detail.tsx | 20 +++++++- src/components/interests/interest-detail.tsx | 28 +++--------- src/components/shared/detail-not-found.tsx | 48 ++++++++++++++++++++ src/components/yachts/yacht-detail.tsx | 24 +++++++++- 6 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 src/components/shared/detail-not-found.tsx diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index ebbc2282..b5609eaa 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -1,10 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; +import { useSearchParams, useRouter, useParams } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { apiFetch } from '@/lib/api/client'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -17,10 +18,18 @@ interface BerthDetailProps { } export function BerthDetail({ berthId }: BerthDetailProps) { - const { data, isLoading } = useQuery({ + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading, error } = useQuery({ queryKey: ['berth', berthId], queryFn: () => apiFetch<{ data: BerthDetailData }>(`/api/v1/berths/${berthId}`).then((r) => r.data), + retry: (failureCount, err) => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 404 || status === 403) return false; + return failureCount < 2; + }, }); useRealtimeInvalidation({ @@ -56,6 +65,18 @@ export function BerthDetail({ berthId }: BerthDetailProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); + if (error && !isLoading) { + const status = (error as { status?: number } | null | undefined)?.status; + return ( + + ); + } + const berth = data; return ( diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index 219e01b2..37270ab3 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -2,8 +2,10 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'next/navigation'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { getClientTabs } from '@/components/clients/client-tabs'; @@ -79,10 +81,18 @@ interface ClientDetailProps { } export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { - const { data, isLoading } = useQuery({ + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading, error } = useQuery({ queryKey: ['clients', clientId], queryFn: () => apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data), + retry: (failureCount, err) => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 404 || status === 403) return false; + return failureCount < 2; + }, }); const { setChrome } = useMobileChrome(); @@ -108,6 +118,18 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { 'berth_reservation:cancelled': [['clients', clientId]], }); + if (error && !isLoading) { + const status = (error as { status?: number } | null | undefined)?.status; + return ( + + ); + } + const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : []; return ( diff --git a/src/components/companies/company-detail.tsx b/src/components/companies/company-detail.tsx index cec855a4..6649d719 100644 --- a/src/components/companies/company-detail.tsx +++ b/src/components/companies/company-detail.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { CompanyDetailHeader } from '@/components/companies/company-detail-header'; import { getCompanyTabs } from '@/components/companies/company-tabs'; @@ -42,10 +43,15 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['companies', companyId], queryFn: () => apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data), + retry: (failureCount, err) => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 404 || status === 403) return false; + return failureCount < 2; + }, }); const { setChrome } = useMobileChrome(); @@ -65,6 +71,18 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) 'company_membership:ended': [['companies', companyId, 'members']], }); + if (error && !isLoading) { + const status = (error as { status?: number } | null | undefined)?.status; + return ( + + ); + } + const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : []; return ( diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index a5e3e720..cc5ea135 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -3,10 +3,9 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; -import { SearchX } from 'lucide-react'; import { DetailLayout } from '@/components/shared/detail-layout'; -import { EmptyState } from '@/components/shared/empty-state'; +import { DetailNotFound } from '@/components/shared/detail-not-found'; import { InterestDetailHeader } from '@/components/interests/interest-detail-header'; import { getInterestTabs } from '@/components/interests/interest-tabs'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; @@ -129,29 +128,14 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp ); // F17: explicit "not found" state when the API 404'd or 403'd. - // Pre-fix the page silently rendered the layout shell with empty tabs, - // leaving users unsure whether the interest existed or just hadn't - // loaded. Cross-port URL pastes now land here with a clear message. if (error && !isLoading) { const status = (error as { status?: number } | null | undefined)?.status; return ( - { - window.location.assign(`/${portSlug}/interests`); - }, - }} + ); } diff --git a/src/components/shared/detail-not-found.tsx b/src/components/shared/detail-not-found.tsx new file mode 100644 index 00000000..cd4dede5 --- /dev/null +++ b/src/components/shared/detail-not-found.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { SearchX } from 'lucide-react'; + +import { EmptyState } from '@/components/shared/empty-state'; + +interface DetailNotFoundProps { + /** "interest", "client", "yacht", etc. — used to build the copy. */ + entity: string; + /** Plural list path back-link. e.g. "/port-x/clients". */ + backHref: string; + /** Plural label for the back button, e.g. "Back to clients". */ + backLabel: string; + /** HTTP status code from the failed fetch (404 vs 403 changes copy). */ + status?: number; +} + +/** + * Renders an explicit "not found" / "no access" panel for entity detail + * pages whose data fetch failed. Replaces the prior silent-list-shell + * behaviour where a wrong-port URL paste rendered the navigation chrome + * with empty tabs and no error message. Post-audit F17. + */ +export function DetailNotFound({ entity, backHref, backLabel, status }: DetailNotFoundProps) { + const denied = status === 403; + return ( + { + window.location.assign(backHref); + }, + }} + /> + ); +} + +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/components/yachts/yacht-detail.tsx b/src/components/yachts/yacht-detail.tsx index 5cd5bf2c..6c21a5d1 100644 --- a/src/components/yachts/yacht-detail.tsx +++ b/src/components/yachts/yacht-detail.tsx @@ -2,8 +2,10 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'next/navigation'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header'; import { getYachtTabs } from '@/components/yachts/yacht-tabs'; @@ -43,9 +45,17 @@ interface YachtDetailProps { } export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) { - const { data, isLoading } = useQuery({ + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading, error } = useQuery({ queryKey: ['yachts', yachtId], queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data), + retry: (failureCount, err) => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 404 || status === 403) return false; + return failureCount < 2; + }, }); const { setChrome } = useMobileChrome(); @@ -66,6 +76,18 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) { ], }); + if (error && !isLoading) { + const status = (error as { status?: number } | null | undefined)?.status; + return ( + + ); + } + const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : []; return (