feat(mobile): show entity name in mobile topbar on detail pages

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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-01 15:46:32 +02:00
parent 9eadaf035e
commit 7e8110b2ff
8 changed files with 101 additions and 20 deletions

View File

@@ -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;

View File

@@ -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]],

View File

@@ -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]],

View File

@@ -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: () => {

View File

@@ -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<string, string> = {
@@ -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: () => {

View File

@@ -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<MobileChromeApi>(
() => ({
...state,
setChrome: (next) => setState((prev) => ({ ...prev, ...next })),
}),
[state],
);
const setChrome = useCallback((next: Partial<MobileChromeState>) => {
setState((prev) => ({ ...prev, ...next }));
}, []);
const value = useMemo<MobileChromeApi>(() => ({ ...state, setChrome }), [state, setChrome]);
return <MobileChromeContext.Provider value={value}>{children}</MobileChromeContext.Provider>;
}
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 `<MobileLayout>`, 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 <MobileLayoutProvider>');
}
return ctx;
return ctx ?? NOOP_CHROME;
}

View File

@@ -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 (
<div className={cn('flex flex-col min-h-full', className)}>
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 sm:px-6">
{/* Desktop-only sticky header — mobile topbar covers this on small viewports. */}
<div className="hidden sm:block sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 sm:px-6">
<div className="flex items-center gap-3 min-w-0">
<h2 className="truncate text-lg font-semibold text-foreground">{entityName}</h2>
{status ? <div className="ml-auto shrink-0">{status}</div> : null}
</div>
</div>
<div className={cn('flex-1 px-4 py-4 sm:px-6 sm:py-6', bottomActions && 'pb-24 sm:pb-6')}>
{/* Mobile inline status row — only shown when the page wants to display a status pill. */}
{status ? (
<div className="sm:hidden flex items-center justify-end px-1 pt-1">
<div className="shrink-0">{status}</div>
</div>
) : null}
<div className={cn('flex-1 sm:px-6 sm:py-6', bottomActions && 'pb-24 sm:pb-6')}>
{children}
</div>

View File

@@ -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]],