530 lines
23 KiB
JavaScript
530 lines
23 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import {
|
|
Activity, Bell, Bike, Car, ChevronDown, Coins, FileSpreadsheet,
|
|
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
|
ShieldAlert, User, UserCheck, Warehouse,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { DOORMILE_MARK_URL } from '@/assets/brand';
|
|
import { Avatar } from '@/components/ds/Avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { useAuth } from '@/lib/AuthContext';
|
|
import { useExceptions } from '@/lib/doormileHooks';
|
|
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
|
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
|
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
|
|
|
|
/**
|
|
* The console shell.
|
|
*
|
|
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
|
|
* indicator, mobile sheet.
|
|
*
|
|
* Core destinations sit on the bar; the rest live in two grouped menus on it
|
|
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
|
|
* holds the two destinations that are configuration rather than operations
|
|
* (Pricing Matrix, Customers Directory).
|
|
*
|
|
* Said precisely because the previous wording — "three grouped menus (Fleet
|
|
* Ops, Reports, Settings)" — sent a reader looking for a third NAV_GROUPS
|
|
* entry that does not exist, and hid the fact that the Settings pair is
|
|
* reached by a different route from everything else. That difference is
|
|
* exactly what made them unreachable on mobile.
|
|
*/
|
|
|
|
const ROOT = '/doormile';
|
|
|
|
const LOGO_FILTER = {
|
|
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
|
|
};
|
|
|
|
/** Destinations that sit directly on the header bar. */
|
|
const NAV = [
|
|
{ label: 'Control X', path: '/doormile/control-x' },
|
|
{ label: 'Bookings', path: '/doormile/bookings' },
|
|
{ label: 'Orders', path: '/doormile/orders' },
|
|
{ label: 'Deliveries', path: '/doormile/deliveries' },
|
|
{ label: 'Milers', path: '/doormile/milers' },
|
|
{ label: 'Clients', path: '/doormile/tenants' },
|
|
];
|
|
|
|
/** Destinations grouped behind a menu, in the order the console lists them. */
|
|
const NAV_GROUPS = [
|
|
{
|
|
label: 'Fleet Ops',
|
|
items: [
|
|
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse },
|
|
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
|
|
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
|
|
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
|
|
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
|
|
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
|
|
],
|
|
},
|
|
{
|
|
label: 'Reports',
|
|
items: [
|
|
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet },
|
|
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText },
|
|
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike },
|
|
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins },
|
|
],
|
|
},
|
|
];
|
|
|
|
function GlobalSearch() {
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = useState('');
|
|
const [focused, setFocused] = useState(false);
|
|
const inputRef = useRef(null);
|
|
|
|
/* Cmd/Ctrl+K focuses search — jump-to control shortcut */
|
|
useEffect(() => {
|
|
const onKey = (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
/* Orders is the console's search surface: it holds every booking, and
|
|
an operator searching from the header is nearly always chasing one
|
|
order number or one customer. */
|
|
if (query.trim()) navigate(`/doormile/orders?search=${encodeURIComponent(query.trim())}`);
|
|
}}
|
|
role="search"
|
|
className={cn(
|
|
'relative hidden items-center transition-all duration-base ease-out md:flex',
|
|
focused || query ? 'w-64' : 'w-44'
|
|
)}
|
|
>
|
|
<Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
placeholder="Search orders"
|
|
aria-label="Search orders"
|
|
className="h-8 w-full rounded-lg border border-border bg-surface-subtle pl-8 pr-10 text-body-sm text-ink-1
|
|
placeholder:text-ink-4 outline-none transition-colors
|
|
focus:border-brand focus:bg-surface focus:ring-2 focus:ring-brand/15"
|
|
/>
|
|
{!focused && !query && (
|
|
<kbd className="pointer-events-none absolute right-2 rounded border border-border bg-surface px-1 text-[10px] font-medium text-ink-4">
|
|
⌘K
|
|
</kbd>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open exceptions, as notifications.
|
|
*
|
|
* The console's real "needs attention" queue is the exceptions register — a
|
|
* failed delivery, a damaged parcel, an address that could not be found — so
|
|
* that is what the bell shows, rather than a second notion of alerting that
|
|
* nothing writes to.
|
|
*/
|
|
function NotificationsMenu() {
|
|
const navigate = useNavigate();
|
|
const { data } = useExceptions({ refetchInterval: 60_000 });
|
|
|
|
const items = useMemo(() => {
|
|
const open = (data || []).filter(
|
|
(row) => !['resolved', 'closed'].includes(String(row?.status || '').toLowerCase())
|
|
);
|
|
return open.slice(0, 8).map((row) => ({
|
|
id: row.exceptionid ?? row.id,
|
|
severity: String(row.severity || '').toLowerCase() === 'high' ? 'critical' : 'warning',
|
|
title: row.exceptiontype || row.type || 'Exception raised',
|
|
detail: row.description || row.reason || 'No description recorded.',
|
|
time: formatDoormileTimestamp(row.createdat, 'DD MMM, hh:mm A', ''),
|
|
}));
|
|
}, [data]);
|
|
|
|
const unread = items.length;
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={unread ? `Notifications, ${unread} open exceptions` : 'Notifications'}
|
|
className="relative grid h-8 w-8 place-items-center rounded-lg text-ink-3 transition-colors
|
|
hover:bg-surface-sunken hover:text-ink-1
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
|
|
>
|
|
<Bell className="h-4 w-4" aria-hidden="true" />
|
|
{unread > 0 && (
|
|
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-brand ring-2 ring-white" />
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
|
|
<p className="text-body-sm font-semibold text-ink-1">Open exceptions</p>
|
|
{unread > 0 && <Badge variant="soft" size="sm">{unread} open</Badge>}
|
|
</div>
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{items.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-body-sm text-ink-3">Nothing needs attention.</p>
|
|
) : (
|
|
items.map((n) => (
|
|
<button
|
|
key={n.id}
|
|
type="button"
|
|
onClick={() => navigate('/doormile/exceptions')}
|
|
className="flex w-full gap-2.5 border-b border-border bg-brand-tint/40 px-3 py-2.5 text-left
|
|
last:border-0 hover:bg-brand-tint/70"
|
|
>
|
|
<span
|
|
className={cn(
|
|
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
|
|
n.severity === 'critical' ? 'bg-destructive' : 'bg-warning'
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-body-sm font-medium text-ink-1">{n.title}</p>
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{n.detail}</p>
|
|
{n.time && <p className="mt-1 text-[10px] text-ink-4">{n.time}</p>}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export default function AdminLayout() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { user, isClient, logout } = useAuth();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [assistantOpen, setAssistantOpen] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('doormileAssistantOpen');
|
|
if (saved !== null) return saved === 'true';
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return typeof window !== 'undefined' ? window.innerWidth >= 1024 : true;
|
|
});
|
|
|
|
const toggleAssistant = (nextState) => {
|
|
setAssistantOpen((prev) => {
|
|
const next = typeof nextState === 'function' ? nextState(prev) : nextState;
|
|
try {
|
|
localStorage.setItem('doormileAssistantOpen', String(next));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const navItems = useMemo(() => {
|
|
if (isClient) {
|
|
return NAV.filter((item) => item.path !== '/doormile/tenants');
|
|
}
|
|
return NAV;
|
|
}, [isClient]);
|
|
|
|
const navGroups = useMemo(() => {
|
|
if (isClient) {
|
|
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops');
|
|
}
|
|
return NAV_GROUPS;
|
|
}, [isClient]);
|
|
|
|
const allDestinations = useMemo(() => {
|
|
return [...navItems, ...navGroups.flatMap((group) => group.items)];
|
|
}, [navItems, navGroups]);
|
|
|
|
const isActive = (path) => {
|
|
if (path === ROOT) return location.pathname === ROOT;
|
|
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
|
};
|
|
|
|
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
|
|
|
|
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
|
|
const isHome = location.pathname === '/doormile/home' || location.pathname === '/doormile' || location.pathname === '/';
|
|
|
|
return (
|
|
/* Transparent so the ambient canvas painted behind the app reads through.
|
|
An opaque shell here would cover it and every page would lose the tint at
|
|
once — which is why the canvas is one layer and not a background repeated
|
|
on each page. */
|
|
<div className="min-h-screen">
|
|
{/* `data-app-header` is read by the Doormile AI dock, which measures this
|
|
bar to know where the panel should start. */}
|
|
<header data-app-header className="sticky top-0 z-50 border-b border-border bg-white/85 backdrop-blur-xl">
|
|
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
|
|
<Link
|
|
to="/doormile/home"
|
|
aria-label="Doormile Express — Home"
|
|
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 transition-transform active:scale-95"
|
|
>
|
|
<img
|
|
src={DOORMILE_MARK_URL}
|
|
alt="Doormile"
|
|
className="h-8 w-auto object-contain"
|
|
/>
|
|
</Link>
|
|
|
|
{/* Text tabs with a hairline indicator, not filled pills. Each grouped
|
|
menu is styled as one more tab, so a destination reached through a
|
|
menu still reads as being in the same row as the rest. */}
|
|
<nav aria-label="Console" className="hidden min-w-0 flex-1 items-center gap-0.5 lg:flex">
|
|
{navItems.map((item) => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
aria-current={isActive(item.path) ? 'page' : undefined}
|
|
className={cn(
|
|
'relative whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium transition-colors duration-base',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
|
isActive(item.path) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
|
)}
|
|
>
|
|
{item.label}
|
|
{isActive(item.path) && (
|
|
<motion.span
|
|
layoutId="adminNavIndicator"
|
|
className="absolute inset-x-2.5 -bottom-[13px] h-0.5 rounded-full bg-brand"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
|
/>
|
|
)}
|
|
</Link>
|
|
))}
|
|
|
|
{navGroups.map((group) => (
|
|
<DropdownMenu key={group.label}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'relative flex items-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
|
|
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer',
|
|
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
|
)}
|
|
>
|
|
{group.icon && <group.icon className="h-3.5 w-3.5 opacity-70" />}
|
|
<span>{group.label}</span>
|
|
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden="true" />
|
|
{groupIsActive(group) && (
|
|
<motion.span
|
|
layoutId="adminNavIndicator"
|
|
className="absolute inset-x-2.5 -bottom-[13px] h-0.5 rounded-full bg-brand"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
|
|
{group.items.map((item) => {
|
|
const ItemIcon = item.icon;
|
|
const active = isActive(item.path);
|
|
return (
|
|
<DropdownMenuItem
|
|
key={item.path}
|
|
onClick={() => navigate(item.path)}
|
|
className={cn(
|
|
// items-center, not items-start: with the second
|
|
// line gone there is nothing to top-align to, and
|
|
// the icon would sit high against a single label.
|
|
'flex items-center gap-2.5 p-2 rounded-xl cursor-pointer transition-colors my-0.5',
|
|
active ? 'bg-brand-tint/80 text-brand' : 'hover:bg-slate-50 text-ink-1'
|
|
)}
|
|
>
|
|
{ItemIcon && (
|
|
<div
|
|
className={cn(
|
|
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
|
|
active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600'
|
|
)}
|
|
>
|
|
<ItemIcon className="w-3.5 h-3.5" />
|
|
</div>
|
|
)}
|
|
{/* Label only. The descriptions under each item
|
|
("Sortation centers & facilities", "Linehaul &
|
|
middle-mile runs") were removed: this is a jump
|
|
menu for people who already know the console, so a
|
|
gloss on every row is read once and then skipped
|
|
past forever while still costing the height that
|
|
made the menu tall enough to cover the page. */}
|
|
<div className="min-w-0 flex-1 text-body-sm font-semibold">{item.label}</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<GlobalSearch />
|
|
|
|
{/* MileTruth's control is no longer here. It is the floating
|
|
launcher at the bottom of the page (AILauncher, mounted beside
|
|
the panel below), so this row holds only the utilities that
|
|
belong in a header: search, alerts, the account menu. */}
|
|
|
|
{!isClient && <NotificationsMenu />}
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 rounded-lg py-1 pl-1 pr-1.5 transition-colors hover:bg-surface-sunken
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer"
|
|
>
|
|
<Avatar name={displayName} size="sm" />
|
|
<ChevronDown className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
|
<span className="sr-only">Account menu</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent align="end" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
|
|
<DropdownMenuLabel className="font-normal px-2.5 py-2">
|
|
<p className="text-body-sm font-bold text-ink-1">{displayName}</p>
|
|
<p className="text-caption text-ink-3 truncate">{user?.email || user?.primaryemail || 'admin@doormile.com'}</p>
|
|
<Badge variant="soft" size="sm" className="mt-1.5 bg-rose-50 text-rose-700 font-semibold border-rose-200">
|
|
{user?.role || 'admin'}
|
|
</Badge>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator className="my-1" />
|
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
|
|
<User className="mr-2.5 h-4 w-4 text-slate-500" /> Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
|
|
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator className="my-1" />
|
|
<DropdownMenuItem
|
|
onClick={() => navigate('/doormile/settings')}
|
|
className={cn(
|
|
'cursor-pointer rounded-xl px-2.5 py-2 font-medium',
|
|
location.pathname.startsWith('/doormile/settings') && 'bg-brand-tint text-brand font-semibold'
|
|
)}
|
|
>
|
|
<Settings className="mr-2.5 h-4 w-4 text-slate-500" /> Settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator className="my-1" />
|
|
<DropdownMenuItem
|
|
onClick={() => logout()}
|
|
className="cursor-pointer text-destructive focus:text-destructive rounded-xl px-2.5 py-2 font-medium"
|
|
>
|
|
<LogOut className="mr-2.5 h-4 w-4" /> Sign out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen(true)}
|
|
aria-label="Open navigation"
|
|
className="grid h-8 w-8 place-items-center rounded-lg text-ink-3 hover:bg-surface-sunken hover:text-ink-1 lg:hidden
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
|
|
>
|
|
<Menu className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile menu Sheet — flat, because a menu inside a drawer is one tap
|
|
too many on the device where taps are most expensive. */}
|
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
|
<SheetContent side="right" className="w-72 p-0">
|
|
<SheetHeader className="border-b border-border px-4 py-4">
|
|
<SheetTitle className="text-left font-heading text-title">Doormile Express</SheetTitle>
|
|
</SheetHeader>
|
|
<nav className="flex max-h-[calc(100dvh-8rem)] flex-col overflow-y-auto p-2">
|
|
{allDestinations.map((item) => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
|
|
isActive(item.path) ? 'bg-brand-tint text-brand' : 'text-ink-2 hover:bg-surface-sunken'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
{/* Settings — the pair that lives in the account menu on desktop.
|
|
Without this block they existed on the bar's dropdown only, so
|
|
a phone could not reach Pricing or Customers at all. */}
|
|
<div className="my-2 h-px bg-border" />
|
|
<Link
|
|
to="/doormile/settings"
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
|
|
isActive('/doormile/settings') ? 'bg-brand-tint text-brand font-semibold' : 'text-ink-2 hover:bg-surface-sunken'
|
|
)}
|
|
>
|
|
Settings
|
|
</Link>
|
|
<div className="my-2 h-px bg-border" />
|
|
<Link
|
|
to="/doormile/profile"
|
|
onClick={() => setMenuOpen(false)}
|
|
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
|
|
>
|
|
Profile
|
|
</Link>
|
|
</nav>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* `dai-page` is the handshake with the assistant dock: while the panel
|
|
is open, DoormileAI.css reserves exactly its width of padding here so
|
|
the page slides open beside it instead of being covered. */}
|
|
<div className={cn("dai-page flex flex-col flex-1", !location.pathname.startsWith('/doormile/control-x') && "px-4 md:flex-row lg:px-6")}>
|
|
<main className={cn(
|
|
"min-w-0 flex-1 flex flex-col",
|
|
!location.pathname.startsWith('/doormile/control-x') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
|
|
)}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
|
|
{/* Both guarded by the SAME condition, deliberately adjacent: the home
|
|
page runs the assistant as the page itself, so neither the docked
|
|
panel nor its launcher belongs there. Splitting these two rules apart
|
|
is how you end up with a button that opens nothing. */}
|
|
{!isHome && (
|
|
<>
|
|
<AILauncher isOpen={assistantOpen} onToggle={() => toggleAssistant((open) => !open)} />
|
|
<AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|