From 7e8110b2ffe37897cf8e84146ed74c9a36dfd906 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 1 May 2026 15:46:32 +0200 Subject: [PATCH] feat(mobile): show entity name in mobile topbar on detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail pages (clients, yachts, companies, berths, invoices, expenses) now push their entity name + a back-button toggle to the mobile topbar via useMobileChrome, replacing the URL UUID fallback that was rendering before. Supporting changes: - useMobileChrome() no longer throws when called outside the MobileLayoutProvider — desktop-tree consumers get a no-op setChrome so callers don't have to branch on shell type. - setChrome is now stable across renders (useCallback) so callers' useEffect dependency arrays don't infinite-loop. - DetailPageShell now also pushes its entityName + cleans up on unmount, and hides its desktop-only sticky header on mobile so it doesn't double up with the topbar (no current callers, prep for Phase 4 migration). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/berths/berth-detail.tsx | 9 +++++ src/components/clients/client-detail.tsx | 9 +++++ src/components/companies/company-detail.tsx | 9 +++++ src/components/expenses/expense-detail.tsx | 11 ++++++- src/components/invoices/invoice-detail.tsx | 10 +++++- .../layout/mobile/mobile-layout-provider.tsx | 33 +++++++++++-------- src/components/shared/detail-page-shell.tsx | 31 ++++++++++++++--- src/components/yachts/yacht-detail.tsx | 9 +++++ 8 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index be84773..dd77b8c 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -1,8 +1,10 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { apiFetch } from '@/lib/api/client'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { BerthDetailHeader } from './berth-detail-header'; @@ -26,6 +28,13 @@ export function BerthDetail({ berthId }: BerthDetailProps) { 'berth:statusChanged': [['berth', berthId]], }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.mooringNumber ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const berth = data as any; diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index 3de3f97..2c6e77c 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -1,8 +1,10 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { getClientTabs } from '@/components/clients/client-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -80,6 +82,13 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data), }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.fullName ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + useRealtimeInvalidation({ 'client:updated': [['clients', clientId]], 'client:archived': [['clients', clientId]], diff --git a/src/components/companies/company-detail.tsx b/src/components/companies/company-detail.tsx index f2da262..4b267a0 100644 --- a/src/components/companies/company-detail.tsx +++ b/src/components/companies/company-detail.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { CompanyDetailHeader } from '@/components/companies/company-detail-header'; import { getCompanyTabs } from '@/components/companies/company-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -45,6 +47,13 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data), }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.name ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + useRealtimeInvalidation({ 'company:updated': [['companies', companyId]], 'company:archived': [['companies', companyId]], diff --git a/src/components/expenses/expense-detail.tsx b/src/components/expenses/expense-detail.tsx index 7e77485..59d5d51 100644 --- a/src/components/expenses/expense-detail.tsx +++ b/src/components/expenses/expense-detail.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; import { Loader2, Receipt, Edit, Archive } from 'lucide-react'; @@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { apiFetch } from '@/lib/api/client'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import type { ExpenseRow } from './expense-columns'; import { ExpenseDuplicateBanner } from './expense-duplicate-banner'; @@ -34,6 +35,14 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`), }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = + data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null; + useEffect(() => { + setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + const archiveMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }), onSuccess: () => { diff --git a/src/components/invoices/invoice-detail.tsx b/src/components/invoices/invoice-detail.tsx index b078b01..ed6b11c 100644 --- a/src/components/invoices/invoice-detail.tsx +++ b/src/components/invoices/invoice-detail.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Send, CreditCard } from 'lucide-react'; @@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { InvoicePdfPreview } from './invoice-pdf-preview'; import { apiFetch } from '@/lib/api/client'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { recordPaymentSchema, type RecordPaymentInput } from '@/lib/validators/invoices'; const STATUS_COLORS: Record = { @@ -40,6 +41,13 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { queryFn: () => apiFetch<{ data: any }>(`/api/v1/invoices/${invoiceId}`), }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.data?.invoiceNumber ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + const sendMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }), onSuccess: () => { diff --git a/src/components/layout/mobile/mobile-layout-provider.tsx b/src/components/layout/mobile/mobile-layout-provider.tsx index 4a96be6..8858e4a 100644 --- a/src/components/layout/mobile/mobile-layout-provider.tsx +++ b/src/components/layout/mobile/mobile-layout-provider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; type MobileChromeState = { title: string | null; @@ -21,26 +21,31 @@ export function MobileLayoutProvider({ children }: { children: ReactNode }) { showBackButton: false, }); - const value = useMemo( - () => ({ - ...state, - setChrome: (next) => setState((prev) => ({ ...prev, ...next })), - }), - [state], - ); + const setChrome = useCallback((next: Partial) => { + setState((prev) => ({ ...prev, ...next })); + }, []); + + const value = useMemo(() => ({ ...state, setChrome }), [state, setChrome]); return {children}; } +const NOOP_SET_CHROME = () => {}; +const NOOP_CHROME: MobileChromeApi = { + title: null, + primaryAction: null, + showBackButton: false, + setChrome: NOOP_SET_CHROME, +}; + /** * Page-level hook to push a title / back-button / primary action into the - * mobile topbar. The provider is only mounted by ``, so - * desktop-shell renders never call into this context. + * mobile topbar. Both the desktop and mobile shells render the same + * children, so this hook MUST be safe to call from either tree. When the + * provider is missing (desktop tree), it returns a no-op so callers don't + * have to branch on shell type. */ export function useMobileChrome() { const ctx = useContext(MobileChromeContext); - if (!ctx) { - throw new Error('useMobileChrome must be used inside '); - } - return ctx; + return ctx ?? NOOP_CHROME; } diff --git a/src/components/shared/detail-page-shell.tsx b/src/components/shared/detail-page-shell.tsx index 4dbad15..e1042a6 100644 --- a/src/components/shared/detail-page-shell.tsx +++ b/src/components/shared/detail-page-shell.tsx @@ -1,15 +1,21 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; /** * Wrapper for entity detail pages (clients, yachts, companies, etc.). Renders: - * - sticky compact header (entity name + status pill) + * - desktop sticky compact header (entity name + status pill) * - the children (existing tab dropdown selector + tab body) * - optional sticky bottom action shelf, pinned above the bottom tab bar on * mobile (`pb-[calc(56px+env(safe-area-inset-bottom))]` content padding). + * + * Mobile: the desktop sticky header is hidden because the mobile topbar already + * shows the entity name (pushed via `useMobileChrome`). The optional back + * button is also enabled on mobile so detail pages get an arrow back to the + * list. */ export function DetailPageShell({ entityName, @@ -24,16 +30,33 @@ export function DetailPageShell({ bottomActions?: ReactNode; className?: string; }) { + const { setChrome } = useMobileChrome(); + + useEffect(() => { + setChrome({ title: entityName, showBackButton: true }); + return () => { + setChrome({ title: null, showBackButton: false }); + }; + }, [entityName, setChrome]); + return (
-
+ {/* Desktop-only sticky header — mobile topbar covers this on small viewports. */} +

{entityName}

{status ?
{status}
: null}
-
+ {/* Mobile inline status row — only shown when the page wants to display a status pill. */} + {status ? ( +
+
{status}
+
+ ) : null} + +
{children}
diff --git a/src/components/yachts/yacht-detail.tsx b/src/components/yachts/yacht-detail.tsx index 348d552..9a1954a 100644 --- a/src/components/yachts/yacht-detail.tsx +++ b/src/components/yachts/yacht-detail.tsx @@ -1,8 +1,10 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header'; import { getYachtTabs } from '@/components/yachts/yacht-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -45,6 +47,13 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) { queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data), }); + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.name ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + useRealtimeInvalidation({ 'yacht:updated': [['yachts', yachtId]], 'yacht:archived': [['yachts', yachtId]],