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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]],
|
||||
|
||||
Reference in New Issue
Block a user