mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
953 lines
31 KiB
TypeScript
953 lines
31 KiB
TypeScript
import {
|
||
Alert,
|
||
AppShell,
|
||
Avatar,
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Divider,
|
||
FileInput,
|
||
Group,
|
||
Menu,
|
||
Modal,
|
||
NavLink,
|
||
ScrollArea,
|
||
Stack,
|
||
Text,
|
||
UnstyledButton,
|
||
useMantineTheme,
|
||
} from "@mantine/core";
|
||
import { useDisclosure } from "@mantine/hooks";
|
||
import {
|
||
Ban,
|
||
ChevronDown,
|
||
FileSignature,
|
||
LogOut,
|
||
Menu as MenuIcon,
|
||
Plus,
|
||
RefreshCw,
|
||
// Search,
|
||
Settings,
|
||
Upload,
|
||
User,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Fragment, type ReactNode, useState } from "react";
|
||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||
import SupportWidget from "@/features/support/SupportWidget";
|
||
|
||
export interface SidebarItem {
|
||
label: string;
|
||
href: string;
|
||
icon?: ReactNode;
|
||
children?: SidebarItem[];
|
||
section?: string;
|
||
}
|
||
|
||
export interface AppLayoutProps {
|
||
title?: string;
|
||
sidebarItems: SidebarItem[];
|
||
activeHref?: string;
|
||
/**
|
||
* Navigate to a route. Accepts an optional options object (e.g. `{ state }`)
|
||
* forwarded to the router — used to pass navigation state like `fresh: true`
|
||
* to the new-booking wizard. Compatible with react-router's `navigate`.
|
||
*/
|
||
onNavigate?: (href: string, options?: { state?: unknown }) => void;
|
||
userName?: string;
|
||
userEmail?: string;
|
||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||
companyProfiles?: {
|
||
id?: string;
|
||
type: string;
|
||
reference: string;
|
||
status?: string;
|
||
reviewNote?: string | null;
|
||
}[];
|
||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||
companyType?: string | null;
|
||
/**
|
||
* Render the floating support-chat launcher. Defaults to true so the customer
|
||
* portal is unaffected; shipping lines pass false — support chat is scoped to
|
||
* a company, which they do not have.
|
||
*/
|
||
showSupportWidget?: boolean;
|
||
/** Create a new service profile of the given type (with business license). */
|
||
onCreateProfile?: (
|
||
type: ServiceType,
|
||
licenseFiles: File[],
|
||
) => Promise<SwitchResult> | void;
|
||
/** Resubmit a rejected service for approval, optionally replacing its license. */
|
||
onReapplyProfile?: (
|
||
profileId: string,
|
||
licenseFiles: File[],
|
||
) => Promise<SwitchResult> | void;
|
||
children: ReactNode;
|
||
}
|
||
|
||
/** Service profiles a customer company can operate under and switch between. */
|
||
type ServiceType = "importer" | "exporter" | "freight_forwarder";
|
||
|
||
/** Services a customer company can select in the header. */
|
||
const CUSTOMER_SERVICES: ServiceType[] = [
|
||
"importer",
|
||
"exporter",
|
||
"freight_forwarder",
|
||
];
|
||
type SwitchResult =
|
||
| { success: true; data?: unknown }
|
||
| { success: false; error?: { message?: string } };
|
||
|
||
function getInitials(name: string): string {
|
||
return name
|
||
.split(" ")
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.map((n) => n[0].toUpperCase())
|
||
.join("");
|
||
}
|
||
|
||
function getActivePage(
|
||
items: SidebarItem[],
|
||
activePath: string,
|
||
): { label: string } | null {
|
||
const path = activePath.toLowerCase();
|
||
|
||
// Longest match wins, for the same reason as the sidebar's isItemActive:
|
||
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
|
||
// parent, which a first-match-wins scan would report as "Home".
|
||
const best = items
|
||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||
.filter(
|
||
(item) =>
|
||
path === item.href.toLowerCase() ||
|
||
path.startsWith(item.href.toLowerCase() + "/"),
|
||
)
|
||
.sort((a, b) => b.href.length - a.href.length)[0];
|
||
|
||
return best ? { label: best.label } : null;
|
||
}
|
||
|
||
const navClassNames = (active: boolean) => {
|
||
if (active) {
|
||
// Active tab gets the strong brand color: a green gradient pill, white
|
||
// label + icon, and a soft lifted shadow so it clearly stands out from the
|
||
// light rail around it.
|
||
return {
|
||
root: `rounded-[12px] font-medium transition-all duration-150 bg-gradient-to-r from-[#0EA371] to-[#0A8A60]! shadow-[0_6px_16px_rgba(14,163,113,0.30)]!`,
|
||
label: `text-white! font-bold!`,
|
||
section: `text-white!`,
|
||
};
|
||
}
|
||
return {
|
||
root: `rounded-[12px] font-medium transition-all duration-150 hover:bg-[#EBF4EF]!`,
|
||
label: `text-edr-text! font-semibold! hover:text-[#0A6F4D]!`,
|
||
section: `text-[#64748B]! hover:text-[#0A6F4D]!`,
|
||
};
|
||
};
|
||
|
||
export function AppLayout({
|
||
title = "EDR Freight",
|
||
sidebarItems,
|
||
activeHref = "",
|
||
onNavigate,
|
||
userName = "User",
|
||
userEmail,
|
||
companyProfiles = [],
|
||
companyType,
|
||
onCreateProfile,
|
||
onReapplyProfile,
|
||
showSupportWidget = true,
|
||
children,
|
||
}: AppLayoutProps) {
|
||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||
const theme = useMantineTheme();
|
||
const borderColor = theme.colors["edr-border"][6];
|
||
const mutedColor = theme.colors["edr-muted"][6];
|
||
const textColor = theme.colors["edr-text"][6];
|
||
const primaryColor = theme.colors["edr-green"][5];
|
||
const primaryDarkColor = theme.colors["edr-green"][7];
|
||
|
||
const activePath = activeHref.toLowerCase();
|
||
const navigate = (href: string, options?: { state?: unknown }) =>
|
||
onNavigate?.(href, options);
|
||
|
||
const initials = getInitials(userName);
|
||
const activePage = getActivePage(sidebarItems, activePath);
|
||
|
||
// ── Add a service (customer companies only) ──
|
||
// A customer can operate as importer, exporter and/or freight forwarder. The
|
||
// header lets them ADD a service they don't have yet (creating a profile with
|
||
// its business license). Data is no longer scoped by an "active" service —
|
||
// every page shows all the company's data, with an optional per-page filter.
|
||
const isCustomer = companyType === "customer";
|
||
const profileExists = (type: ServiceType) =>
|
||
companyProfiles.some((p) => p.type === type);
|
||
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
|
||
// Rejected services can't be re-added (they exist), so they'd otherwise be
|
||
// invisible here — surface them for resubmission alongside addable ones.
|
||
const rejectedServices = isCustomer
|
||
? companyProfiles.filter(
|
||
(p) =>
|
||
p.status === "rejected" &&
|
||
p.id &&
|
||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||
)
|
||
: [];
|
||
// Suspended services are also hidden by default (the profile exists) — surface
|
||
// them so the customer can appeal by resubmitting a fresh business license.
|
||
const suspendedServices = isCustomer
|
||
? companyProfiles.filter(
|
||
(p) =>
|
||
p.status === "suspended" &&
|
||
p.id &&
|
||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||
)
|
||
: [];
|
||
const canManageServices =
|
||
isCustomer &&
|
||
(addableServices.length > 0 ||
|
||
rejectedServices.length > 0 ||
|
||
suspendedServices.length > 0);
|
||
|
||
const [switching, setSwitching] = useState(false);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
|
||
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
||
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
|
||
// the modal copy; null for a brand-new profile.
|
||
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
|
||
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
|
||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||
const [createError, setCreateError] = useState<string | null>(null);
|
||
|
||
const openServiceModal = (
|
||
type: ServiceType,
|
||
profile?: { id?: string; status?: string; reviewNote?: string | null },
|
||
) => {
|
||
setCreateTarget(type);
|
||
setReapplyId(profile?.id ?? null);
|
||
setReapplyStatus(profile?.status ?? null);
|
||
setReapplyNote(profile?.reviewNote ?? null);
|
||
setLicenseFiles([]);
|
||
setCreateError(null);
|
||
setCreateOpen(true);
|
||
};
|
||
|
||
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||
|
||
const handleCreateConfirm = async () => {
|
||
const isReapply = reapplyId !== null;
|
||
// A new profile needs its license up front; a resubmit may reuse the old one.
|
||
if (!isReapply && licenseFiles.length === 0) {
|
||
setCreateError("Please upload at least one business license file.");
|
||
return;
|
||
}
|
||
setSwitching(true);
|
||
setCreateError(null);
|
||
try {
|
||
const res = isReapply
|
||
? await onReapplyProfile?.(reapplyId, licenseFiles)
|
||
: await onCreateProfile?.(createTarget, licenseFiles);
|
||
if (res && !res.success) {
|
||
setCreateError(res.error?.message ?? "Failed to submit service");
|
||
return;
|
||
}
|
||
setCreateOpen(false);
|
||
setReapplyId(null);
|
||
} finally {
|
||
setSwitching(false);
|
||
}
|
||
};
|
||
|
||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||
|
||
// Longest matching href wins. A plain prefix test would light up every
|
||
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
|
||
// bookings", Home would stay highlighted on every page beneath it. Exact
|
||
// matches still win outright, so customer routes are unaffected — their
|
||
// sidebar hrefs are siblings, never nested inside one another.
|
||
const bestMatchHref = sidebarItems
|
||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||
.map((item) => item.href.toLowerCase())
|
||
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
|
||
.sort((a, b) => b.length - a.length)[0];
|
||
|
||
const isItemActive = (item: SidebarItem) =>
|
||
bestMatchHref === item.href.toLowerCase();
|
||
|
||
return (
|
||
<AppShell
|
||
layout="alt"
|
||
navbar={{
|
||
width: 260,
|
||
breakpoint: "sm",
|
||
collapsed: { mobile: !mobileOpen },
|
||
}}
|
||
header={{ height: 72 }}
|
||
padding={0}
|
||
>
|
||
{/* ── Header (frosted glass; compact floating islands, mirrors the pen) ── */}
|
||
<AppShell.Header
|
||
withBorder={false}
|
||
style={{
|
||
// Solid white surface with a hairline base and a soft drop so it
|
||
// floats above the content area.
|
||
background: "#FFFFFF",
|
||
borderBottom: `1px solid ${borderColor}`,
|
||
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
|
||
}}
|
||
>
|
||
<Group
|
||
h="100%"
|
||
px={24}
|
||
pt={12}
|
||
pb={8}
|
||
justify="space-between"
|
||
wrap="nowrap"
|
||
>
|
||
{/* Left: sidebar toggle + page title */}
|
||
<Group gap={12} wrap="nowrap" align="center">
|
||
<UnstyledButton
|
||
onClick={toggleMobile}
|
||
hiddenFrom="sm"
|
||
aria-label="Toggle sidebar"
|
||
>
|
||
<MenuIcon size={18} color={textColor} strokeWidth={1.8} />
|
||
</UnstyledButton>
|
||
<Text
|
||
style={{
|
||
fontFamily: '"Inter", sans-serif',
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
color: textColor,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{activePage ? activePage.label : title}
|
||
</Text>
|
||
</Group>
|
||
|
||
{/* Right: switch + search + bell + avatar */}
|
||
<Group gap={10} wrap="nowrap" align="center">
|
||
{/* Add a service, or resubmit a rejected one (customer companies) */}
|
||
{canManageServices && (
|
||
<Menu
|
||
width={240}
|
||
position="bottom-end"
|
||
withinPortal
|
||
shadow="md"
|
||
offset={8}
|
||
radius="md"
|
||
>
|
||
<Menu.Target>
|
||
<Button
|
||
loading={switching}
|
||
variant="light"
|
||
color="edr-green"
|
||
radius={999}
|
||
size="sm"
|
||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
|
||
styles={{ root: { height: 36 } }}
|
||
visibleFrom="xs"
|
||
>
|
||
Add service
|
||
</Button>
|
||
</Menu.Target>
|
||
<Menu.Dropdown>
|
||
{addableServices.length > 0 && (
|
||
<>
|
||
<Menu.Label>Add a service</Menu.Label>
|
||
{addableServices.map((type) => (
|
||
<Menu.Item
|
||
key={type}
|
||
onClick={() => handleAddService(type)}
|
||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||
>
|
||
{serviceLabel(type)}
|
||
</Menu.Item>
|
||
))}
|
||
</>
|
||
)}
|
||
{rejectedServices.length > 0 && (
|
||
<>
|
||
{addableServices.length > 0 && <Menu.Divider />}
|
||
<Menu.Label>Rejected — resubmit</Menu.Label>
|
||
{rejectedServices.map((p) => (
|
||
<Menu.Item
|
||
key={p.id}
|
||
color="red"
|
||
onClick={() =>
|
||
openServiceModal(p.type as ServiceType, p)
|
||
}
|
||
leftSection={
|
||
<RefreshCw size={15} strokeWidth={1.8} />
|
||
}
|
||
>
|
||
{serviceLabel(p.type as ServiceType)}
|
||
</Menu.Item>
|
||
))}
|
||
</>
|
||
)}
|
||
{suspendedServices.length > 0 && (
|
||
<>
|
||
{(addableServices.length > 0 ||
|
||
rejectedServices.length > 0) && <Menu.Divider />}
|
||
<Menu.Label>Suspended — appeal</Menu.Label>
|
||
{suspendedServices.map((p) => (
|
||
<Menu.Item
|
||
key={p.id}
|
||
color="orange"
|
||
onClick={() =>
|
||
openServiceModal(p.type as ServiceType, p)
|
||
}
|
||
leftSection={<Ban size={15} strokeWidth={1.8} />}
|
||
rightSection={
|
||
<Badge size="xs" color="orange" variant="light">
|
||
Suspended
|
||
</Badge>
|
||
}
|
||
>
|
||
{serviceLabel(p.type as ServiceType)}
|
||
</Menu.Item>
|
||
))}
|
||
</>
|
||
)}
|
||
</Menu.Dropdown>
|
||
</Menu>
|
||
)}
|
||
|
||
{/* Search pill */}
|
||
{/* <Group
|
||
gap={8}
|
||
align="center"
|
||
visibleFrom="sm"
|
||
style={{
|
||
width: 260,
|
||
height: 36,
|
||
borderRadius: 999,
|
||
border: `1px solid ${borderColor}`,
|
||
backgroundColor: "#fff",
|
||
padding: "0 14px",
|
||
cursor: "text",
|
||
}}
|
||
>
|
||
<Search size={15} color={mutedColor} strokeWidth={1.8} />
|
||
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
|
||
Search shipments, bookings…
|
||
</Text>
|
||
</Group> */}
|
||
|
||
{/* Notifications */}
|
||
<NotificationBellContainer />
|
||
|
||
{/* Avatar pill */}
|
||
<Menu
|
||
width={220}
|
||
position="bottom-end"
|
||
withinPortal
|
||
shadow="md"
|
||
offset={8}
|
||
radius="md"
|
||
>
|
||
<Menu.Target>
|
||
<UnstyledButton
|
||
style={{
|
||
height: 36,
|
||
borderRadius: 999,
|
||
border: `1px solid ${borderColor}`,
|
||
backgroundColor: "#fff",
|
||
padding: "0 10px 0 4px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 7,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<Avatar radius="xl" size={28} color="edr-green.5">
|
||
<Text fw={700} fz={12} c="white">
|
||
{initials}
|
||
</Text>
|
||
</Avatar>
|
||
<ChevronDown size={15} color={mutedColor} strokeWidth={1.8} />
|
||
</UnstyledButton>
|
||
</Menu.Target>
|
||
|
||
<Menu.Dropdown>
|
||
<Box px="sm" py="xs">
|
||
<Text
|
||
size="sm"
|
||
fw={600}
|
||
truncate
|
||
style={{ color: textColor }}
|
||
>
|
||
{userName}
|
||
</Text>
|
||
{userEmail && (
|
||
<Text size="xs" c="dimmed" truncate>
|
||
{userEmail}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
{companyProfiles.length > 0 && (
|
||
<>
|
||
<Divider />
|
||
<Box px="sm" py="xs">
|
||
<Stack gap={6}>
|
||
{companyProfiles.map((p) => (
|
||
<Group
|
||
key={p.reference}
|
||
justify="space-between"
|
||
gap="sm"
|
||
wrap="nowrap"
|
||
>
|
||
<Text
|
||
size="xs"
|
||
fw={600}
|
||
style={{ color: textColor }}
|
||
>
|
||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||
</Text>
|
||
<Text size="xs" ff="monospace" c="dimmed">
|
||
{p.reference}
|
||
</Text>
|
||
</Group>
|
||
))}
|
||
</Stack>
|
||
</Box>
|
||
</>
|
||
)}
|
||
<Divider />
|
||
<Menu.Item
|
||
leftSection={<FileSignature size={15} />}
|
||
onClick={() => navigate("/signature")}
|
||
>
|
||
Signature & Stamp
|
||
</Menu.Item>
|
||
<Menu.Item
|
||
leftSection={<User size={15} />}
|
||
onClick={() => navigate("/profile")}
|
||
>
|
||
Profile
|
||
</Menu.Item>
|
||
<Menu.Item
|
||
leftSection={<Settings size={15} />}
|
||
onClick={() => navigate("/settings")}
|
||
>
|
||
Settings
|
||
</Menu.Item>
|
||
<Divider />
|
||
<Menu.Item
|
||
leftSection={<Plus size={15} />}
|
||
color="edr-green"
|
||
onClick={() =>
|
||
navigate("/bookings/new", { state: { fresh: true } })
|
||
}
|
||
>
|
||
New Contract
|
||
</Menu.Item>
|
||
<Divider />
|
||
<Menu.Item
|
||
leftSection={<LogOut size={15} />}
|
||
color="red"
|
||
onClick={() => navigate("/logout")}
|
||
>
|
||
Logout
|
||
</Menu.Item>
|
||
</Menu.Dropdown>
|
||
</Menu>
|
||
</Group>
|
||
</Group>
|
||
</AppShell.Header>
|
||
|
||
{/* ── Sidebar ── */}
|
||
<AppShell.Navbar
|
||
withBorder={false}
|
||
style={{
|
||
// Clean white rail.
|
||
background: "#FFFFFF",
|
||
borderRight: `1px solid ${borderColor}`,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* Brand */}
|
||
<Box
|
||
style={{
|
||
height: 60,
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "0 18px",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<Group gap={11} wrap="nowrap">
|
||
<img
|
||
src="/assets/edr-logo.png"
|
||
alt="EDR"
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
objectFit: "contain",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<Box>
|
||
<Text
|
||
style={{
|
||
fontFamily: '"Inter", sans-serif',
|
||
fontWeight: 800,
|
||
fontSize: 19,
|
||
letterSpacing: "0.03em",
|
||
color: primaryColor,
|
||
lineHeight: 1.15,
|
||
}}
|
||
>
|
||
EDR FREIGHT
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 10.5,
|
||
fontWeight: 500,
|
||
color: mutedColor,
|
||
lineHeight: 1.2,
|
||
}}
|
||
>
|
||
Ethio–Djibouti Railway
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
<UnstyledButton
|
||
onClick={toggleMobile}
|
||
hiddenFrom="sm"
|
||
aria-label="Close sidebar"
|
||
>
|
||
<X size={18} color={mutedColor} strokeWidth={1.8} />
|
||
</UnstyledButton>
|
||
</Box>
|
||
|
||
{/* Nav */}
|
||
<ScrollArea flex={1} type="never" p="sm">
|
||
<Stack gap={3}>
|
||
{sidebarItems.map((item, i) => {
|
||
const active = isItemActive(item);
|
||
const hasChildren = !!item.children?.length;
|
||
const childActive =
|
||
item.children?.some((c) =>
|
||
activePath.startsWith(c.href.toLowerCase()),
|
||
) ?? false;
|
||
|
||
const prevSection = sidebarItems[i - 1]?.section;
|
||
const sectionLabel =
|
||
item.section && item.section !== prevSection ? (
|
||
<Text
|
||
key={`section-${item.section}`}
|
||
tt="uppercase"
|
||
px="sm"
|
||
mt={i === 0 ? 6 : "lg"}
|
||
mb={6}
|
||
style={{
|
||
fontWeight: 700,
|
||
color: "#94A3B8",
|
||
fontSize: 10.5,
|
||
letterSpacing: "0.08em",
|
||
}}
|
||
>
|
||
{item.section}
|
||
</Text>
|
||
) : null;
|
||
|
||
if (hasChildren) {
|
||
return (
|
||
<Fragment key={item.href}>
|
||
{sectionLabel}
|
||
<NavLink
|
||
label={item.label}
|
||
leftSection={item.icon}
|
||
active={active || childActive}
|
||
defaultOpened={childActive}
|
||
classNames={navClassNames(active || childActive)}
|
||
>
|
||
{item.children!.map((child) => {
|
||
const cActive = activePath === child.href.toLowerCase();
|
||
return (
|
||
<NavLink
|
||
key={child.href}
|
||
label={child.label}
|
||
active={cActive}
|
||
onClick={() => navigate(child.href)}
|
||
classNames={navClassNames(cActive)}
|
||
/>
|
||
);
|
||
})}
|
||
</NavLink>
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Fragment key={item.href}>
|
||
{sectionLabel}
|
||
<NavLink
|
||
label={item.label}
|
||
leftSection={item.icon}
|
||
active={active}
|
||
onClick={() => navigate(item.href)}
|
||
classNames={navClassNames(active)}
|
||
/>
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</ScrollArea>
|
||
|
||
{/* Promo card */}
|
||
<Box style={{ padding: "0 12px 12px" }}>
|
||
<Box
|
||
style={{
|
||
borderRadius: 16,
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
height: 260,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
{/* Train image fills the full card */}
|
||
<img
|
||
src="/assets/train-edr.jpg"
|
||
alt=""
|
||
style={{
|
||
position: "absolute",
|
||
bottom: "-16px",
|
||
left: 0,
|
||
right: "-70px",
|
||
width: "120%",
|
||
objectFit: "cover",
|
||
}}
|
||
/>
|
||
|
||
{/* Diagonal green overlay: lower-left → upper-right cut */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
top: "-75%",
|
||
background:
|
||
"linear-gradient(165deg, #006149 40%, #0DC9A4 70%, #0DC9A402 80%)",
|
||
clipPath: "polygon(0 0, 100% 0, 100% 58%, 0 72%)",
|
||
}}
|
||
/>
|
||
|
||
{/* Text sits on top of the green overlay */}
|
||
<Box
|
||
style={{
|
||
position: "relative",
|
||
zIndex: 1,
|
||
padding: "16px 16px 0",
|
||
}}
|
||
className="max-w-[180px]"
|
||
>
|
||
<Text
|
||
style={{
|
||
fontSize: 15,
|
||
fontWeight: 800,
|
||
color: "#fff",
|
||
lineHeight: 1.3,
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
Moving Africa Forward
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 11,
|
||
color: "rgba(255,255,255,0.7)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
Reliable. Efficient. Connected.
|
||
</Text>
|
||
</Box>
|
||
|
||
{/* Learn More — visible on the image area */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 10,
|
||
right: 10,
|
||
zIndex: 2,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
backgroundColor: "#fff",
|
||
borderRadius: 8,
|
||
padding: "6px 12px",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: primaryDarkColor,
|
||
}}
|
||
>
|
||
Learn More
|
||
</Text>
|
||
<span style={{ fontSize: 12, color: primaryDarkColor }}>→</span>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Profile */}
|
||
<Box
|
||
style={{
|
||
borderRadius: 12,
|
||
border: `1px solid ${borderColor}`,
|
||
padding: "10px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<Avatar
|
||
radius="xl"
|
||
size={38}
|
||
color="edr-green.5"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<Text fw={600} fz={13} c="white">
|
||
{initials}
|
||
</Text>
|
||
</Avatar>
|
||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||
<Text
|
||
size="sm"
|
||
fw={600}
|
||
truncate
|
||
style={{ color: textColor, lineHeight: 1.3 }}
|
||
>
|
||
{userName}
|
||
</Text>
|
||
{userEmail && (
|
||
<Text
|
||
size="xs"
|
||
truncate
|
||
style={{ color: mutedColor, lineHeight: 1.3 }}
|
||
>
|
||
{userEmail}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
<ChevronDown
|
||
size={16}
|
||
color={mutedColor}
|
||
style={{ flexShrink: 0 }}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
</AppShell.Navbar>
|
||
|
||
{/* ── Main ── */}
|
||
<AppShell.Main
|
||
style={{
|
||
backgroundColor: "##f8fafc",
|
||
// Extra bottom clearance so the fixed support-chat FAB never
|
||
// overlaps page content, even at the bottom of a scrolled page.
|
||
paddingBottom: 112,
|
||
}}
|
||
>
|
||
{children}
|
||
</AppShell.Main>
|
||
|
||
{/* Floating customer-support chat launcher. Hidden when the caller opts
|
||
out: support chat resolves the user's external profile → company, and
|
||
a shipping line has neither, so every poll would 403. */}
|
||
{showSupportWidget && <SupportWidget />}
|
||
|
||
{/* Create-profile modal — opens when switching to a mode the company
|
||
doesn't have a profile for yet. */}
|
||
<Modal
|
||
opened={createOpen}
|
||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||
title={
|
||
isSuspendedAppeal
|
||
? `Appeal suspension — ${serviceLabel(createTarget)}`
|
||
: reapplyId
|
||
? `Resubmit your ${serviceLabel(createTarget)} service`
|
||
: `Set up your ${serviceLabel(createTarget)} profile`
|
||
}
|
||
centered
|
||
radius="lg"
|
||
>
|
||
<Stack gap="md">
|
||
<Text size="sm" c="dimmed">
|
||
{isSuspendedAppeal
|
||
? `Your ${serviceLabel(
|
||
createTarget,
|
||
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
|
||
: reapplyId
|
||
? `Your ${serviceLabel(
|
||
createTarget,
|
||
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
||
: `You don't have a ${serviceLabel(
|
||
createTarget,
|
||
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||
</Text>
|
||
{isSuspendedAppeal && reapplyNote && (
|
||
<Alert
|
||
color="orange"
|
||
variant="light"
|
||
icon={<Ban size={16} />}
|
||
title="Reason for suspension"
|
||
>
|
||
{reapplyNote}
|
||
</Alert>
|
||
)}
|
||
<FileInput
|
||
label={
|
||
reapplyId ? "Business license (optional)" : "Business license"
|
||
}
|
||
multiple
|
||
clearable
|
||
accept="application/pdf,image/png,image/jpeg"
|
||
leftSection={<Upload size={16} />}
|
||
placeholder="Select license file(s)"
|
||
value={licenseFiles}
|
||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||
error={createError ?? undefined}
|
||
/>
|
||
<Group justify="flex-end" gap="sm">
|
||
<Button
|
||
variant="default"
|
||
onClick={() => setCreateOpen(false)}
|
||
disabled={switching}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
color="edr-green"
|
||
onClick={handleCreateConfirm}
|
||
loading={switching}
|
||
>
|
||
{isSuspendedAppeal
|
||
? "Submit appeal"
|
||
: reapplyId
|
||
? "Resubmit"
|
||
: "Create"}
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
export default AppLayout;
|