chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -61,7 +61,7 @@ function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier {
|
||||
* #26 + H-09: single-tree responsive shell with stable children subtree.
|
||||
*
|
||||
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
|
||||
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs
|
||||
* viewports - only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs
|
||||
* vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs:
|
||||
*
|
||||
* - #26 / first ship: no double-mount of chrome subtrees.
|
||||
@@ -74,7 +74,7 @@ function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier {
|
||||
* the mobile shell. Closes the half-screen-on-13"-Mac usability gap.
|
||||
*
|
||||
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
|
||||
* mount in the mobile branch — they have no desktop counterpart.
|
||||
* mount in the mobile branch - they have no desktop counterpart.
|
||||
*
|
||||
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
|
||||
* the first client render uses the same value so hydration matches. After
|
||||
@@ -103,7 +103,7 @@ export function AppShell({
|
||||
const next: Tier = mqMobile.matches ? 'mobile' : mqTablet.matches ? 'tablet' : 'desktop';
|
||||
setTier(next);
|
||||
// Persist for the next SSR pass so the server renders the
|
||||
// matching shell on first paint — eliminates the chrome flicker
|
||||
// matching shell on first paint - eliminates the chrome flicker
|
||||
// on refresh when UA-based classification disagrees with the
|
||||
// actual viewport (most common on macOS Safari at sub-1024
|
||||
// widths). 1-year expiry; SameSite=Lax is fine since the cookie
|
||||
@@ -187,7 +187,7 @@ export function AppShell({
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-6 w-6 object-contain" />
|
||||
) : (
|
||||
// Neutral fallback when the port has no branding logo yet —
|
||||
// Neutral fallback when the port has no branding logo yet -
|
||||
// a three-bar menu icon keeps the affordance discoverable.
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -13,7 +13,7 @@ type TabSpec = {
|
||||
};
|
||||
|
||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
||||
// Search occupies the center slot. Documents demoted to the MoreSheet —
|
||||
// Search occupies the center slot. Documents demoted to the MoreSheet -
|
||||
// reps reach docs less often than berths during a walking inventory check,
|
||||
// and pinned-to-client documents are accessed via the client detail anyway.
|
||||
const TABS_LEFT: TabSpec[] = [
|
||||
@@ -50,7 +50,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||
))}
|
||||
|
||||
{/* Search button — styled identically to the other navbar tabs. */}
|
||||
{/* Search button - styled identically to the other navbar tabs. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchClick}
|
||||
@@ -89,7 +89,7 @@ function NavTab({ tab, portSlug, active }: { tab: TabSpec; portSlug: string; act
|
||||
)}
|
||||
>
|
||||
{/* iOS-native active indicator: a 2px accent bar at the top of
|
||||
the active tab. Cleaner than a colored pill — relies on the
|
||||
the active tab. Cleaner than a colored pill - relies on the
|
||||
icon + label color change (text-primary above) to do the
|
||||
primary signaling, with this bar adding just enough visual
|
||||
anchor to read as "selected". */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
||||
|
||||
/**
|
||||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||||
* bar. Mounted by AppShell when the viewport classifies as mobile — never
|
||||
* bar. Mounted by AppShell when the viewport classifies as mobile - never
|
||||
* concurrent with the desktop tree. The bottom tabs and More sheet derive
|
||||
* the active port slug from the URL themselves, so this layout takes no
|
||||
* portSlug prop.
|
||||
|
||||
@@ -21,7 +21,7 @@ export function MobileTopbar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// UUID detection — the URL's last segment on detail pages is the
|
||||
// UUID detection - the URL's last segment on detail pages is the
|
||||
// entity's UUID, and title-casing it produces an ugly "Abc 123 Uuid"
|
||||
// flash before the page calls `useMobileChrome.setChrome({title: ...})`
|
||||
// with the real entity name. When the segment matches the UUID shape,
|
||||
@@ -32,7 +32,7 @@ export function MobileTopbar() {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(last);
|
||||
const fallbackSegment = isUuid ? segments[segments.length - 2] : last;
|
||||
// Derive a sensible title from the current path slug when no
|
||||
// page-level title is set. Avoids hardcoding a specific tenant name —
|
||||
// page-level title is set. Avoids hardcoding a specific tenant name -
|
||||
// a fresh deploy with port slug `marina-alpha` reads as "Marina Alpha"
|
||||
// here without code edits.
|
||||
const portSlug = segments[0] ?? '';
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Anchor,
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileSignature,
|
||||
@@ -18,8 +16,6 @@ import {
|
||||
Ship,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -28,7 +24,6 @@ import {
|
||||
DrawerClose,
|
||||
} from '@/components/shared/drawer';
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type MoreItem = {
|
||||
label: string;
|
||||
@@ -42,7 +37,7 @@ type MoreGroup = {
|
||||
};
|
||||
|
||||
// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable
|
||||
// spatial layout — reps' muscle memory survives — while making the
|
||||
// spatial layout - reps' muscle memory survives - while making the
|
||||
// "kind of thing" each tile is explicit. Three sections:
|
||||
// - Records: entity lists (people, vessels, properties)
|
||||
// - Operations: daily-use action surfaces
|
||||
@@ -51,7 +46,7 @@ type MoreGroup = {
|
||||
// Interests stays here (not bottom nav) to dodge the Clients-vs-
|
||||
// Interests UX confusion. Inbox replaces the previously-separate
|
||||
// Alerts + Reminders entries (merged 2026-05-11). Website analytics
|
||||
// and Reservations are filtered out below when not applicable.
|
||||
// is filtered out below when Umami isn't wired up for this port.
|
||||
const MORE_GROUPS: MoreGroup[] = [
|
||||
{
|
||||
label: 'Records',
|
||||
@@ -67,12 +62,10 @@ const MORE_GROUPS: MoreGroup[] = [
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
|
||||
// M-U15: invoices was missing from the mobile nav — reps doing
|
||||
// M-U15: invoices was missing from the mobile nav - reps doing
|
||||
// mobile follow-ups had to type the URL by hand.
|
||||
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -95,30 +88,16 @@ export function MoreSheet({
|
||||
const pathname = usePathname();
|
||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||
|
||||
// Hide "Website analytics" if Umami isn't wired up for this port — the
|
||||
// Hide "Website analytics" if Umami isn't wired up for this port - the
|
||||
// dedicated tile on the dashboard already does the same.
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Hide "Reservations" until at least one exists for this port — until the
|
||||
// marina has confirmed bookings, the page is empty and surfaces nothing
|
||||
// useful. Cheap count via pageSize=1; cached 5 min so opening the sheet
|
||||
// repeatedly doesn't refetch.
|
||||
const reservations = useQuery<{ pagination?: { total: number } }>({
|
||||
queryKey: ['berth-reservations', 'sheet-count'],
|
||||
queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
const hasReservations =
|
||||
!reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0;
|
||||
|
||||
// Per-group filter: keep only the items relevant to this port's state.
|
||||
const groups = MORE_GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((item) => {
|
||||
if (item.segment === 'website-analytics') return umamiConfigured;
|
||||
if (item.segment === 'berth-reservations') return hasReservations;
|
||||
return true;
|
||||
}),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
@@ -112,7 +112,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
// Marketing / Umami integration. Distinct from the main dashboard
|
||||
// (which is sales-focused) so the audience and the metrics don't
|
||||
// compete for visual real estate. Whole section is hidden when
|
||||
// Umami isn't wired up — see SidebarContent.
|
||||
// Umami isn't wired up - see SidebarContent.
|
||||
{
|
||||
href: `${base}/website-analytics`,
|
||||
label: 'Website analytics',
|
||||
@@ -130,7 +130,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
// Invoices nav entry removed — the expense-to-PDF flow is the
|
||||
// Invoices nav entry removed - the expense-to-PDF flow is the
|
||||
// only invoicing surface now (employee expense reports). The
|
||||
// standalone /invoices route still exists for any back-compat
|
||||
// links but is no longer surfaced in nav.
|
||||
@@ -151,7 +151,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
// Email tab removed: deferred building a full inbox/threading
|
||||
// feature (would require Google OAuth + scope review + IMAP
|
||||
// syncing infra). This entry routes to the merged
|
||||
// Alerts + Reminders surface (2026-05-11) — explicit name so
|
||||
// Alerts + Reminders surface (2026-05-11) - explicit name so
|
||||
// reps don't mistake it for an email inbox.
|
||||
{ href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
|
||||
],
|
||||
@@ -262,7 +262,7 @@ function SidebarContent({
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Small label under the user identity when the user has access to more
|
||||
// than one port — disambiguates which port is currently active without
|
||||
// than one port - disambiguates which port is currently active without
|
||||
// pulling the port name back into the breadcrumbs.
|
||||
const showPortLabel = !!ports && ports.length > 1;
|
||||
const currentPortName = showPortLabel
|
||||
@@ -457,7 +457,7 @@ export function Sidebar({
|
||||
ports,
|
||||
portLogoUrls,
|
||||
}: SidebarProps) {
|
||||
// Sidebar collapse removed — design preference is the always-expanded
|
||||
// Sidebar collapse removed - design preference is the always-expanded
|
||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||
// any code still reading it.
|
||||
const sidebarCollapsed = false;
|
||||
|
||||
@@ -29,7 +29,7 @@ interface TopbarProps {
|
||||
ports: Port[];
|
||||
user?: { name: string; email: string };
|
||||
/** Optional leading slot rendered before the breadcrumbs on tablet
|
||||
* viewports — used by AppShell to mount a sidebar trigger button
|
||||
* viewports - used by AppShell to mount a sidebar trigger button
|
||||
* (logo) when the sidebar is hidden behind a slide-over Sheet. */
|
||||
leadingSlot?: ReactNode;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
// The mobile-chrome flag still wins when a page explicitly opts in.
|
||||
// Pages that already render their own "back to X" link inline
|
||||
// (residential interest detail, expense scan flow, etc.) opt OUT
|
||||
// by setting the chrome flag to false on mount — the flag override
|
||||
// by setting the chrome flag to false on mount - the flag override
|
||||
// path here lets them suppress this auto-show.
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const isDeepPage = segments.length > 2;
|
||||
@@ -69,7 +69,7 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
// Avatar" and pushed the New button off-screen at every tablet +
|
||||
// narrow-desktop width. With the center as a single fr-track, the
|
||||
// right column always gets the space it needs.
|
||||
<header className="grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
<header className="relative grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* LEFT: optional sidebar trigger (tablet) + optional back button + breadcrumbs */}
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
{leadingSlot}
|
||||
@@ -90,18 +90,30 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
{/* CENTER: global search. Caps scale by viewport tier so the search
|
||||
bar doesn't crowd the side columns at narrow widths:
|
||||
base: max-w-md (28rem / 448px) — fits tablet 768-1023 with
|
||||
~150-200px left for the right column.
|
||||
lg: max-w-xl (36rem / 576px) — narrow desktop with sidebar.
|
||||
xl: max-w-2xl (42rem / 672px) — full desktop, plenty of room.
|
||||
The lg: translate-X visually centers the search against the FULL
|
||||
viewport (compensating for the sidebar's 256px on the left). It
|
||||
stays gated to lg+ so tablet (sidebar hidden behind Sheet)
|
||||
doesn't get an unnecessary shift. */}
|
||||
<div className="flex items-center justify-center min-w-0">
|
||||
<div className="w-full max-w-md mx-auto min-w-0 lg:max-w-xl xl:max-w-2xl lg:-translate-x-[calc(var(--width-sidebar)/2)]">
|
||||
{/* CENTER (spacer): the search bar is absolutely positioned below
|
||||
so it anchors to true viewport center regardless of left/right
|
||||
column widths. This empty grid track keeps `auto 1fr auto` so
|
||||
the right column behaves the same as before. */}
|
||||
<div aria-hidden />
|
||||
|
||||
{/* CENTER: global search, anchored to true viewport center.
|
||||
The topbar element starts AFTER the 256px sidebar at lg+, so
|
||||
`left: 50%` of the topbar lands sidebar/2 (=128px) right of the
|
||||
viewport center. We subtract that offset at lg+ so the search
|
||||
bar sits under the browser address bar; below lg the sidebar
|
||||
is hidden behind a Sheet and the topbar spans the full
|
||||
viewport, so plain `left: 50%` is already correct.
|
||||
|
||||
Caps scale by viewport tier so the bar doesn't crowd the side
|
||||
columns:
|
||||
base: max-w-md (28rem)
|
||||
lg: max-w-xl (36rem)
|
||||
xl: max-w-2xl (42rem)
|
||||
The wrapper is pointer-events-none so it doesn't capture
|
||||
clicks meant for the left/right columns underneath; only the
|
||||
input itself receives pointer events. */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/2 lg:left-[calc(50%-var(--width-sidebar)/2)] flex w-full max-w-md -translate-x-1/2 items-center px-4 lg:max-w-xl xl:max-w-2xl">
|
||||
<div className="pointer-events-auto w-full min-w-0">
|
||||
<CommandSearch />
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +188,7 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
trigger={
|
||||
// Button shrunk to match the Avatar's visible footprint so
|
||||
// the hover halo lands as a tight circle behind the avatar
|
||||
// (was h-11 w-11 default — the rounded-full halo extended
|
||||
// (was h-11 w-11 default - the rounded-full halo extended
|
||||
// well past the visible avatar and read as a square glow).
|
||||
<Button variant="ghost" className="rounded-full h-9 w-9 p-0">
|
||||
<Avatar className="w-7 h-7">
|
||||
|
||||
@@ -64,14 +64,14 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
|
||||
// the new X-Port-Id header.
|
||||
queryClient.invalidateQueries();
|
||||
// Remember the choice so the next login lands here automatically.
|
||||
// Fire-and-forget — failure shouldn't block the navigation, and any
|
||||
// Fire-and-forget - failure shouldn't block the navigation, and any
|
||||
// stale value is harmless (the post-login resolver verifies access
|
||||
// before honouring it).
|
||||
void apiFetch('/api/v1/me', {
|
||||
method: 'PATCH',
|
||||
body: { preferences: { defaultPortId: port.id } },
|
||||
}).catch(() => {
|
||||
/* silent — best-effort */
|
||||
/* silent - best-effort */
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/${port.slug}/dashboard` as any);
|
||||
@@ -137,7 +137,7 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
|
||||
// `router.push` to /api/auth/sign-out issued a GET against
|
||||
// better-auth's POST-only endpoint. Safari (and other browsers
|
||||
// serving JSON without a matching renderer) treat that as a
|
||||
// file download — the response landed on disk as a `sign_out`
|
||||
// file download - the response landed on disk as a `sign_out`
|
||||
// file instead of signing the user out. Call the auth client
|
||||
// directly to issue a proper POST, then redirect to /login.
|
||||
onClick={async () => {
|
||||
|
||||
Reference in New Issue
Block a user