fix(F17 ext): apply DetailNotFound to clients/yachts/companies/berths
Refactored the interest-detail 404 pattern into a reusable `<DetailNotFound>` 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 "<Entity> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -17,10 +18,18 @@ interface BerthDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BerthDetail({ berthId }: BerthDetailProps) {
|
export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||||
const { data, isLoading } = useQuery<BerthDetailData>({
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<BerthDetailData>({
|
||||||
queryKey: ['berth', berthId],
|
queryKey: ['berth', berthId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: BerthDetailData }>(`/api/v1/berths/${berthId}`).then((r) => r.data),
|
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({
|
useRealtimeInvalidation({
|
||||||
@@ -56,6 +65,18 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
if (error && !isLoading) {
|
||||||
|
const status = (error as { status?: number } | null | undefined)?.status;
|
||||||
|
return (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="berth"
|
||||||
|
backHref={`/${portSlug}/berths`}
|
||||||
|
backLabel="Back to berths"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const berth = data;
|
const berth = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||||
@@ -79,10 +81,18 @@ interface ClientDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||||
const { data, isLoading } = useQuery<ClientData>({
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<ClientData>({
|
||||||
queryKey: ['clients', clientId],
|
queryKey: ['clients', clientId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
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();
|
const { setChrome } = useMobileChrome();
|
||||||
@@ -108,6 +118,18 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
|||||||
'berth_reservation:cancelled': [['clients', clientId]],
|
'berth_reservation:cancelled': [['clients', clientId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (error && !isLoading) {
|
||||||
|
const status = (error as { status?: number } | null | undefined)?.status;
|
||||||
|
return (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="client"
|
||||||
|
backHref={`/${portSlug}/clients`}
|
||||||
|
backLabel="Back to clients"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
|
const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||||
@@ -42,10 +43,15 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<CompanyData>({
|
const { data, isLoading, error } = useQuery<CompanyData>({
|
||||||
queryKey: ['companies', companyId],
|
queryKey: ['companies', companyId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
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();
|
const { setChrome } = useMobileChrome();
|
||||||
@@ -65,6 +71,18 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
|||||||
'company_membership:ended': [['companies', companyId, 'members']],
|
'company_membership:ended': [['companies', companyId, 'members']],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (error && !isLoading) {
|
||||||
|
const status = (error as { status?: number } | null | undefined)?.status;
|
||||||
|
return (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="company"
|
||||||
|
backHref={`/${portSlug}/companies`}
|
||||||
|
backLabel="Back to companies"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
|
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { SearchX } from 'lucide-react';
|
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
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 { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
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.
|
// 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) {
|
if (error && !isLoading) {
|
||||||
const status = (error as { status?: number } | null | undefined)?.status;
|
const status = (error as { status?: number } | null | undefined)?.status;
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<DetailNotFound
|
||||||
icon={SearchX}
|
entity="interest"
|
||||||
title={status === 403 ? 'No access to this interest' : 'Interest not found'}
|
backHref={`/${portSlug}/interests`}
|
||||||
description={
|
backLabel="Back to interests"
|
||||||
status === 403
|
status={status}
|
||||||
? 'You do not have permission to view this interest in this port.'
|
|
||||||
: 'It may have been removed, archived, or it belongs to a different port. Use the back button or pick a different interest.'
|
|
||||||
}
|
|
||||||
className="mt-16"
|
|
||||||
action={{
|
|
||||||
label: 'Back to interests',
|
|
||||||
// EmptyState only knows about onClick; render a Link-styled
|
|
||||||
// button below so back-nav works without JS-routing surprises.
|
|
||||||
onClick: () => {
|
|
||||||
window.location.assign(`/${portSlug}/interests`);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/components/shared/detail-not-found.tsx
Normal file
48
src/components/shared/detail-not-found.tsx
Normal file
@@ -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 (
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={denied ? `No access to this ${entity}` : `${capitalize(entity)} not found`}
|
||||||
|
description={
|
||||||
|
denied
|
||||||
|
? `You do not have permission to view this ${entity} in this port.`
|
||||||
|
: `It may have been removed, archived, or it belongs to a different port. Use the back button or pick a different ${entity}.`
|
||||||
|
}
|
||||||
|
className="mt-16"
|
||||||
|
action={{
|
||||||
|
label: backLabel,
|
||||||
|
onClick: () => {
|
||||||
|
window.location.assign(backHref);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string) {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
||||||
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
||||||
@@ -43,9 +45,17 @@ interface YachtDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||||
const { data, isLoading } = useQuery<YachtData>({
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<YachtData>({
|
||||||
queryKey: ['yachts', yachtId],
|
queryKey: ['yachts', yachtId],
|
||||||
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
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();
|
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 (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="yacht"
|
||||||
|
backHref={`/${portSlug}/yachts`}
|
||||||
|
backLabel="Back to yachts"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
|
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user