fix(T3): inline tag create + explicit 404 on interest detail

F16 — InlineTagEditor: inline "Create new tag" affordance
  The popover now has a search input at the top. Typing a name that
  doesn't match any existing tag surfaces a "Create new tag: <name>"
  action that POSTs /api/v1/tags then attaches the new id to the entity.
  Reps no longer need to context-switch to Admin → Tags to create the
  first chip. Enter on the input also triggers create-and-attach.

F17 — Interest detail page: explicit not-found state
  Pre-fix, navigating to /port-X/interests/<port-Y-id> 404'd at the API
  but the UI silently rendered the list shell with empty tabs. Cross-
  port URL pastes now show an EmptyState with title "Interest not found"
  + a "Back to interests" CTA. 403 (no access in this port) gets its
  own copy. TanStack Query is told not to retry 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:
2026-05-14 23:03:30 +02:00
parent e7e498dedd
commit 608641c23b
2 changed files with 106 additions and 7 deletions

View File

@@ -3,8 +3,10 @@
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 { InterestDetailHeader } from '@/components/interests/interest-detail-header';
import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
@@ -82,10 +84,17 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<InterestData>({
const { data, isLoading, error } = useQuery<InterestData>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
// F17: don't retry 404s — they're intentional (wrong port, archived,
// deleted). Let the error state render the EmptyState below.
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
useRealtimeInvalidation({
@@ -119,6 +128,34 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
: null,
);
// 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 (
<EmptyState
icon={SearchX}
title={status === 403 ? 'No access to this interest' : 'Interest not found'}
description={
status === 403
? '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`);
},
}}
/>
);
}
const tabs = data
? getInterestTabs({
interestId,