From 41061462ff4d1916e95faf9cf2f53fdb00cb6d17 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 8 Jun 2026 16:05:03 +0300 Subject: [PATCH 01/20] feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard --- apps/edr-freight-web/portal/package.json | 2 + apps/edr-freight-web/portal/src/App.tsx | 46 +- .../portal/src/components/AppLayout.tsx | 427 +++++++++ apps/edr-freight-web/portal/src/main.tsx | 15 +- .../portal/src/pages/MyPortalPage.tsx | 809 +++++++++++------- .../portal/src/theme/mantine.ts | 147 ++++ pnpm-lock.yaml | 6 + 7 files changed, 1110 insertions(+), 342 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/AppLayout.tsx create mode 100644 apps/edr-freight-web/portal/src/theme/mantine.ts diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 582a2fc0b..ae79d7562 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -15,6 +15,8 @@ "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@hookform/resolvers": "^5.4.0", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui-common": "1.1.2", "axios": "^1.7.7", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 36922cf4a..048c11945 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -3,10 +3,9 @@ import { useLocation, Routes, Route, - Navigate, Outlet, } from "react-router-dom"; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; +import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, MapPin, @@ -39,12 +38,37 @@ import BillingPage from "./pages/billing/BillingPage"; import { useEffect } from "react"; const sidebarItems: SidebarItem[] = [ - { label: "Home", href: "/portal", icon: }, - { label: "My Bookings", href: "/bookings", icon: }, - { label: "Tracking", href: "/tracking", icon: }, - { label: "Billing", href: "/billing", icon: }, - { label: "Profile", href: "/profile", icon: }, - { label: "Settings", href: "/settings", icon: }, + { section: "Overview", label: "Home", href: "/portal", icon: }, + { + section: "Operations", + label: "My Bookings", + href: "/bookings", + icon: , + }, + { + section: "Operations", + label: "Tracking", + href: "/tracking", + icon: , + }, + { + section: "Operations", + label: "Billing", + href: "/billing", + icon: , + }, + { + section: "Account", + label: "Profile", + href: "/profile", + icon: , + }, + { + section: "Account", + label: "Settings", + href: "/settings", + icon: , + }, ]; const App = () => { @@ -65,7 +89,7 @@ const App = () => { if (user && location.pathname === "/") navigate("/portal"); else if (!customer && !!isInProtectedRoutes) navigate("/onboarding"); - }, [user, location, customer]); + }, [user, location, customer, customerQuery.isPending]); if (isPending) { return ( @@ -94,7 +118,7 @@ const App = () => { { onLogout={logout} > - + } > } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx new file mode 100644 index 000000000..fcc26ea4a --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -0,0 +1,427 @@ +import { Fragment, type ReactNode, useState } from "react"; +import { + ActionIcon, + AppShell, + Avatar, + Box, + Burger, + Divider, + Group, + Indicator, + Menu, + NavLink, + ScrollArea, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + Bell, + ChevronDown, + Languages, + LogOut, + Moon, + Sun, + Train, + User, +} from "lucide-react"; + +export interface SidebarItem { + label: string; + href: string; + icon?: ReactNode; + children?: SidebarItem[]; + /** Optional group heading; a small label is rendered when it changes. */ + section?: string; +} + +export interface AppLayoutProps { + title?: string; + sidebarItems: SidebarItem[]; + activeHref?: string; + onNavigate?: (href: string) => void; + enableThemeToggle?: boolean; + userName?: string; + userEmail?: string; + onLogout?: () => void; + children: ReactNode; +} + +type Theme = "light" | "dark"; +const THEME_KEY = "edr-theme"; + +function getStoredTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = localStorage.getItem(THEME_KEY); + if (stored === "dark" || stored === "light") return stored; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +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(); + for (const item of items) { + if ( + path === item.href.toLowerCase() || + path.startsWith(item.href.toLowerCase() + "/") + ) { + return { label: item.label }; + } + if (item.children) { + const childMatch = item.children.find( + (c) => + path === c.href.toLowerCase() || + path.startsWith(c.href.toLowerCase() + "/"), + ); + if (childMatch) return { label: childMatch.label }; + } + } + return null; +} + +// Shared NavLink styling — green tint only when active, quiet neutral otherwise. +const navLinkStyles = { + root: { + borderRadius: "var(--mantine-radius-md)", + fontWeight: 500, + }, + label: { fontSize: "var(--mantine-font-size-sm)" }, +} as const; + +export function AppLayout({ + title = "EDR Freight", + sidebarItems, + activeHref = "", + onNavigate, + enableThemeToggle = false, + userName = "User", + userEmail, + onLogout, + children, +}: AppLayoutProps) { + const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); + const [theme, setTheme] = useState(() => + enableThemeToggle ? getStoredTheme() : "light", + ); + + const activePath = activeHref.toLowerCase(); + const navigate = (href: string) => onNavigate?.(href); + + const toggleTheme = () => { + const next: Theme = theme === "dark" ? "light" : "dark"; + setTheme(next); + document.documentElement.classList.toggle("dark", next === "dark"); + localStorage.setItem(THEME_KEY, next); + }; + + const initials = getInitials(userName); + const activePage = getActivePage(sidebarItems, activePath); + + const isItemActive = (item: SidebarItem) => + activePath === item.href.toLowerCase() || + activePath.startsWith(item.href.toLowerCase() + "/"); + + return ( + + {/* ── Header ──────────────────────────────────────────────────────────── */} + + + + + + {activePage ? activePage.label : title} + + + + {/* Right: utility actions + user menu */} + + + + + + + + + + + + {enableThemeToggle && ( + + {theme === "dark" ? : } + + )} + + + + + + + + {initials} + + + + {userName} + + + + + + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/profile")} + > + Profile + + } + color="red" + onClick={onLogout} + > + Logout + + + + + + + + {/* ── Sidebar ─────────────────────────────────────────────────────────── */} + + {/* Brand */} + + + + + + + {title} + + + + + {/* Nav links */} + + + {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 ? ( + + {item.section} + + ) : null; + + if (hasChildren) { + return ( + + {sectionLabel} + + {item.children!.map((child) => { + const cActive = + activePath === child.href.toLowerCase(); + return ( + navigate(child.href)} + styles={navLinkStyles} + /> + ); + })} + + + ); + } + + return ( + + {sectionLabel} + navigate(item.href)} + styles={navLinkStyles} + /> + + ); + })} + + + + {/* Bottom user */} + + + + + {initials} + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + + + + {/* ── Main ────────────────────────────────────────────────────────────── */} + + {children} + + + ); +} + +export default AppLayout; diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 8c4fbeb96..56d6838df 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import "@mantine/core/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; +import { mantineTheme } from "./theme/mantine"; import App from "./App"; @@ -31,10 +34,12 @@ if (!rootElement) { createRoot(document.getElementById("root")!).render( - - - - - + + + + + + + , ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index b6b17e71f..1c97a13f9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -2,42 +2,43 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { format } from "date-fns"; import { useQuery } from "@tanstack/react-query"; +import { + Anchor, + Badge, + Box, + Button, + Card, + Grid, + Group, + Progress, + RingProgress, + SimpleGrid, + Skeleton, + Stack, + Table, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; import { ArrowRight, - Building2, - CheckCircle2, + ArrowUpRight, + CalendarDays, Clock, - DollarSign, - Eye, - LoaderCircle, - Mail, MapPin, - Package, - Phone, + Package2, Plus, Receipt, - Truck, + Train, UploadCloud, - X, } from "lucide-react"; -import { - getCurrentCustomer, - getMyInvoices, - getMyShipments, -} from "@/lib/currentCustomer"; +import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer"; import { formatCurrency } from "@/pages/billing/invoices.mock"; import type { ShipmentStatus } from "@/pages/tracking/shipments.mock"; import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@edr/ui-common"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; const ACTIVE_STATUSES = [ "DRAFT", @@ -46,12 +47,36 @@ const ACTIVE_STATUSES = [ "IN_TRANSIT", ]; +const BOOKING_STATUS_META: Record = { + DRAFT: { label: "Draft", color: "gray" }, + SUBMITTED: { label: "Submitted", color: "blue" }, + PENDING_APPROVAL: { label: "Pending", color: "orange" }, + IN_TRANSIT: { label: "In Transit", color: "teal" }, + COMPLETED: { label: "Completed", color: "edr-green" }, + CANCELLED: { label: "Cancelled", color: "red" }, + REJECTED: { label: "Rejected", color: "red" }, +}; + +const SHIPMENT_STATUS_META: Record = { + "In Transit": { color: "teal" }, + Delivered: { color: "edr-green" }, + Delayed: { color: "red" }, +}; + +const INVOICE_STATUS_META: Record = { + Draft: { color: "gray" }, + Sent: { color: "blue" }, + Paid: { color: "edr-green" }, + Overdue: { color: "red" }, + Cancelled: { color: "gray" }, +}; + export default function MyPortalPage() { - const me = useMemo(() => getCurrentCustomer(), []); + const { user, customer } = useAuth(); const myShipments = useMemo(() => getMyShipments(), []); const myInvoices = useMemo(() => getMyInvoices(), []); - const navigate = useNavigate(); + const [dismissed, setDismissed] = useState(false); const bookingsQuery = useQuery( api.bookings.list.queryOptions({ @@ -71,337 +96,469 @@ export default function MyPortalPage() { const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); - const [dismissed, setDismissed] = useState(false); const totalOutstanding = outstandingInvoices .filter((inv) => inv.currency === "USD") .reduce((sum, inv) => sum + inv.amount, 0); - const totalSpent = myInvoices - .filter((inv) => inv.status === "Paid" && inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); - const recentBookings = myBookings.slice(0, 5); - const recentInvoices = [...myInvoices].slice(0, 4); + const recentBookings = myBookings.slice(0, 6); + const recentInvoices = myInvoices.slice(0, 4); + const completedInvoices = myInvoices.filter( + (inv) => inv.status === "Paid", + ).length; + const invoiceTotal = myInvoices.length || 1; + const paidPct = Math.round((completedInvoices / invoiceTotal) * 100); + + const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const documentsComplete = !!(customer as any)?.documentsComplete; + const hasOutstanding = outstandingInvoices.length > 0; return ( -
-
- {/* Documents banner */} - {!me.documentsComplete && !dismissed && ( -
- -
-

Upload your documents

-

- To enable all account features, please upload your Business - License, TIN Certificate, and National ID / Passport. -

- + {/* ── Document setup notice ───────────────────────────────────── */} + {!documentsComplete && !dismissed && ( + + + + + + + + Finish setting up your account + + + Upload your Business License,{" "} + TIN Certificate, and{" "} + National ID / Passport to unlock all features. + + + + + + )} + + {/* ── Welcome (branded band) ──────────────────────────────────── */} + + {/* faint rail-line motif */} + + + + + Welcome back + + + {displayName} + + + + + + + {/* ── Stats Row ───────────────────────────────────────────────── */} + + } + color="blue" + /> + } + color="teal" + /> + } + color={hasOutstanding ? "red" : "edr-green"} + /> + } + color="edr-green" + ring={{ value: paidPct, color: "edr-green" }} + /> + + + {/* ── Main Grid: bookings + side panel ────────────────────────── */} + + {/* Recent Bookings (left, wider) */} + + + + + Recent Bookings + + Your latest freight requests + + +
- -
- )} + View all + + - {/* Welcome banner */} -
-
-
-
- {me.company.charAt(0)} -
-
-

Welcome back

-

{me.name}

-

- - {me.company} - · - - {me.customerType} - -

-
-
- -
- - - - - - -
-
-
- - {/* Active Shipments */} - - -
- Active Shipments - - Live tracking for your in-flight cargo - -
- - View all - - -
- - - {activeShipments.length === 0 ? ( -

- No shipments currently in transit. -

- ) : ( -
- {activeShipments.slice(0, 4).map((shipment) => ( -
-
- - {shipment.reference} - - -
-

- {shipment.originStation} - - {shipment.destinationStation} -

-
- - - {shipment.currentLocation} - - ETA {shipment.eta} -
-
-
-
-
+ {bookingsQuery.isPending ? ( + + {[1, 2, 3, 4].map((i) => ( + ))} -
- )} - - - - {/* Recent bookings */} - - -
- Recent Bookings - Your latest freight requests -
- - View all - - -
- - - {recentBookings.length === 0 ? ( -

- You haven't booked any freight yet. -

+ + ) : recentBookings.length === 0 ? ( + ) : ( -
- - - - - - - - - - - - {recentBookings.map((booking) => ( - navigate(`/bookings/${booking.id}`)} + +
ReferenceRouteCargoDateStatus
+ + + Reference + Route + Date + Status + + + + {recentBookings.map((booking) => { + const meta = BOOKING_STATUS_META[booking.status]; + return ( + navigate(`/bookings/${booking.id}`)} + > + + + {booking.reference} + + + + + {booking.originYard?.label ?? + booking.originYard?.code ?? + "—"} + {" → "} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} + + + + + {format( + new Date(booking.createdAt), + "MMM d, yyyy", + )} + + + + + {meta?.label ?? booking.status.replace(/_/g, " ")} + + + + ); + })} + +
+ + )} + + + + {/* Right side panel */} + + + {/* Active Shipments */} + + + + Shipments + + In-flight cargo + + + + + All + + + + + {activeShipments.length === 0 ? ( + + ) : ( + + {activeShipments.slice(0, 3).map((shipment) => ( + + ))} + + )} + + + {/* Invoice Summary */} + + + + Invoices + + {outstandingInvoices.length} outstanding + + + + + All + + + + + {recentInvoices.length === 0 ? ( + + ) : ( + + {recentInvoices.map((invoice) => { + const meta = INVOICE_STATUS_META[invoice.status]; + return ( + - - {booking.reference} - - - {booking.originYard?.label ?? booking.originYard?.code ?? "—"} → {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} - - - {booking.freightType === "CONTAINER" ? "Container" : booking.freightType} - - - {format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")} - - - - - - ))} - - -
- )} -
-
- - {/* Invoices */} - - -
- Recent Invoices - - {outstandingInvoices.length} outstanding · {myInvoices.length}{" "} - total - -
- - View all - - -
- - - {recentInvoices.length === 0 ? ( -

- No invoices yet. -

- ) : ( -
- {recentInvoices.map((invoice) => ( -
-
- - -
-

- {formatCurrency(invoice.amount, invoice.currency)} -

-

- - Due {invoice.dueDate} -

-
- ))} -
- )} -
-
-
-
+ + + + + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + + Due {invoice.dueDate} + + + + + + {invoice.status} + + + ); + })} + + )} + + + + + ); } -function ProfileRow({ - icon, +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StatCard({ label, value, + sub, + icon, + color, + ring, }: { - icon: React.ReactNode; label: string; value: string; + sub?: string; + icon: React.ReactNode; + color: string; + ring?: { value: number; color: string }; }) { return ( -
-
{icon}
-
-

{label}

-

{value}

-
-
+ + + + + {label} + + + {value} + + {sub && ( + + {sub} + + )} + + {ring ? ( + + {ring.value}% + + } + /> + ) : ( + + {icon} + + )} + + ); } -function ShipmentBadge({ status }: { status: ShipmentStatus }) { - const styles: Record = { - "In Transit": "bg-muted text-foreground", - Delivered: "bg-primary/10 text-primary", - Delayed: "bg-destructive/10 text-destructive", - }; +function ShipmentCard({ + shipment, +}: { + shipment: ReturnType[number]; +}) { + const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus]; + const color = meta?.color ?? "gray"; return ( - - {status} - + + + {shipment.reference} + + + {shipment.status} + + + + {shipment.originStation} → {shipment.destinationStation} + + + + + + + {shipment.currentLocation} + + + + ETA {shipment.eta} + + + ); } -function BookingBadge({ status }: { status: string }) { - const styles: Record = { - DRAFT: "bg-amber-100 text-amber-700", - SUBMITTED: "bg-primary/10 text-primary", - PENDING_APPROVAL: "bg-muted text-foreground", - IN_TRANSIT: "bg-muted text-foreground", - COMPLETED: "bg-primary/10 text-primary", - CANCELLED: "bg-destructive/10 text-destructive", - REJECTED: "bg-destructive/10 text-destructive", - }; +function EmptyState({ message }: { message: string }) { return ( - - {status.replace(/_/g, " ")} - - ); -} - -function InvoiceBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - Draft: "bg-muted text-muted-foreground", - Sent: "bg-primary/10 text-primary", - Paid: "bg-primary/10 text-primary", - Overdue: "bg-destructive/10 text-destructive", - Cancelled: "bg-amber-100 text-amber-700", - }; - return ( - - {status} - + + {message} + + ); } diff --git a/apps/edr-freight-web/portal/src/theme/mantine.ts b/apps/edr-freight-web/portal/src/theme/mantine.ts new file mode 100644 index 000000000..1a90fc0f8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/theme/mantine.ts @@ -0,0 +1,147 @@ +import { createTheme, type MantineColorsTuple } from "@mantine/core"; + +// Brand accent — used sparingly: primary actions, active nav, key highlights. +const edrGreen: MantineColorsTuple = [ + "#ecfdf5", + "#d1fae5", + "#a7f3d0", + "#6ee7b7", + "#34d399", + "#10b981", + "#059669", + "#047857", + "#065f46", + "#064e3b", +]; + +// Neutral gray ramp tuned for clean, low-contrast surfaces (Stripe/Notion feel). +const neutral: MantineColorsTuple = [ + "#f8fafc", + "#f1f5f9", + "#e8edf2", + "#dbe2ea", + "#c2cbd6", + "#9aa6b4", + "#6b7785", + "#4b5563", + "#2f3742", + "#1c2129", +]; + +export const mantineTheme = createTheme({ + colors: { + "edr-green": edrGreen, + gray: neutral, + }, + primaryColor: "edr-green", + primaryShade: { light: 6, dark: 5 }, + + white: "#ffffff", + black: "#1c2129", + + fontFamily: + 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + + defaultRadius: "md", + + radius: { + xs: "4px", + sm: "6px", + md: "8px", + lg: "12px", + xl: "16px", + }, + + spacing: { + xs: "8px", + sm: "12px", + md: "16px", + lg: "24px", + xl: "32px", + }, + + fontSizes: { + xs: "12px", + sm: "13px", + md: "14px", + lg: "16px", + xl: "18px", + }, + + lineHeights: { + xs: "1.4", + sm: "1.45", + md: "1.55", + lg: "1.55", + xl: "1.5", + }, + + // Hierarchy comes from a strong, exponential size scale (~1.25 modular ratio), + // not from heavy weights. Big steps at the top, calm weights throughout. + // "Plus Jakarta Sans" gives headings a distinctive geometric character while + // body text stays on the neutral system stack. + headings: { + fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)', + fontWeight: "600", + sizes: { + h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "700" }, + h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "650" }, + h3: { fontSize: "23px", lineHeight: "1.3", fontWeight: "600" }, + h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" }, + h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" }, + h6: { fontSize: "13px", lineHeight: "1.45", fontWeight: "600" }, + }, + }, + + shadows: { + xs: "0 1px 2px rgba(16, 24, 40, 0.04)", + sm: "0 1px 3px rgba(16, 24, 40, 0.06), 0 1px 2px rgba(16, 24, 40, 0.04)", + md: "0 4px 12px rgba(16, 24, 40, 0.06)", + }, + + components: { + Card: { + defaultProps: { + radius: "lg", + withBorder: true, + shadow: "none", + padding: "lg", + }, + }, + Button: { + defaultProps: { + radius: "md", + }, + styles: { + root: { fontWeight: 550 }, + }, + }, + Badge: { + defaultProps: { + radius: "sm", + variant: "light", + }, + styles: { + root: { fontWeight: 550, textTransform: "none" }, + }, + }, + Paper: { + defaultProps: { + radius: "lg", + shadow: "none", + withBorder: true, + }, + }, + Table: { + defaultProps: { + verticalSpacing: "sm", + horizontalSpacing: "md", + }, + }, + Title: { + styles: { + root: { letterSpacing: "-0.01em" }, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8071fdce3..1f411c920 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,12 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.76.0(react@19.2.6)) + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.0(react@19.2.6) '@tanstack/react-query': specifier: ^5.59.0 version: 5.100.11(react@19.2.6) From 456603304635c2938518ece3e10d8ae43a377416 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 9 Jun 2026 09:27:14 +0300 Subject: [PATCH 02/20] style: ui revamp --- apps/edr-freight-web/portal/index.html | 6 + .../portal/src/components/AppLayout.tsx | 195 +- .../portal/src/components/auth/AuthLayout.tsx | 142 +- .../portal/src/pages/MyPortalPage.tsx | 319 +-- .../src/pages/bookings/BookingDetailPage.tsx | 2149 ++++++++--------- .../src/pages/bookings/EditBookingPage.tsx | 120 +- .../portal/src/pages/bookings/MyBookings.tsx | 395 +-- .../src/pages/bookings/NewBookingPage.tsx | 95 +- .../new-booking-form/StepIndicator.tsx | 16 +- .../bookings/new-booking-form/shared.tsx | 136 +- .../new-booking-form/step1-contract-type.tsx | 29 +- .../new-booking-form/step2-service-type.tsx | 144 +- .../bookings/new-booking-form/step4-route.tsx | 131 +- .../new-booking-form/step5-cargo-details.tsx | 260 +- .../new-booking-form/step8-review.tsx | 353 ++- .../portal/src/theme/mantine.ts | 6 +- 16 files changed, 2188 insertions(+), 2308 deletions(-) diff --git a/apps/edr-freight-web/portal/index.html b/apps/edr-freight-web/portal/index.html index 2233dc861..61b3dcff7 100644 --- a/apps/edr-freight-web/portal/index.html +++ b/apps/edr-freight-web/portal/index.html @@ -5,6 +5,12 @@ EDR Freight Portal + + + diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index fcc26ea4a..3484ba1f5 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,6 +19,7 @@ import { useDisclosure } from "@mantine/hooks"; import { Bell, ChevronDown, + ChevronRight, Languages, LogOut, Moon, @@ -93,14 +94,24 @@ function getActivePage( return null; } -// Shared NavLink styling — green tint only when active, quiet neutral otherwise. -const navLinkStyles = { - root: { - borderRadius: "var(--mantine-radius-md)", - fontWeight: 500, - }, - label: { fontSize: "var(--mantine-font-size-sm)" }, -} as const; +// NavLink classNames for the dark sidebar — Tailwind utilities (with v4 `!` +// important suffix) override Mantine's default active styling. +const navClassNames = (active: boolean) => { + const base = + "rounded-[10px] font-medium transition-all duration-150 active:scale-[0.98]"; + if (active) { + return { + root: `${base} bg-gradient-to-br! from-emerald-600! to-emerald-500! text-white! shadow-[0_2px_8px_-4px_rgba(16,185,129,0.45)]`, + label: "text-white!", + section: "text-white!", + }; + } + return { + root: `${base} text-white/60! hover:bg-white/[0.07]! hover:text-white!`, + label: "text-inherit!", + section: "text-inherit! opacity-90", + }; +}; export function AppLayout({ title = "EDR Freight", @@ -139,7 +150,7 @@ export function AppLayout({ {/* ── Header ──────────────────────────────────────────────────────────── */} @@ -159,9 +170,24 @@ export function AppLayout({ hiddenFrom="sm" size="sm" /> - - {activePage ? activePage.label : title} - + + + {title} + + + + + + {activePage ? activePage.label : title} + + {/* Right: utility actions + user menu */} @@ -170,16 +196,20 @@ export function AppLayout({ variant="subtle" color="gray" size="lg" + radius="md" + className="transition-transform hover:-translate-y-px" aria-label="Change language" > - + @@ -191,6 +221,8 @@ export function AppLayout({ variant="subtle" color="gray" size="lg" + radius="md" + className="transition-transform hover:-translate-y-px" onClick={toggleTheme} aria-label="Toggle theme" > @@ -198,38 +230,42 @@ export function AppLayout({ )} + + - - + + {initials} - - {userName} - - + + + {userName} + + + Customer + + + @@ -267,49 +303,29 @@ export function AppLayout({ {/* ── Sidebar ─────────────────────────────────────────────────────────── */} {/* Brand */} - + - + - - {title} - + + + {title} + + + Logistics Portal + + {/* Nav links */} - + {sidebarItems.map((item, i) => { const active = isItemActive(item); const hasChildren = !!item.children?.length; @@ -324,13 +340,11 @@ export function AppLayout({ {item.section} @@ -344,23 +358,18 @@ export function AppLayout({ label={item.label} leftSection={item.icon} active={active || childActive} - color="edr-green" - variant="filled" defaultOpened={childActive} - styles={navLinkStyles} + classNames={navClassNames(active || childActive)} > {item.children!.map((child) => { - const cActive = - activePath === child.href.toLowerCase(); + const cActive = activePath === child.href.toLowerCase(); return ( navigate(child.href)} - styles={navLinkStyles} + classNames={navClassNames(cActive)} /> ); })} @@ -376,10 +385,8 @@ export function AppLayout({ label={item.label} leftSection={item.icon} active={active} - color="edr-green" - variant="filled" onClick={() => navigate(item.href)} - styles={navLinkStyles} + classNames={navClassNames(active)} /> ); @@ -388,26 +395,24 @@ export function AppLayout({ {/* Bottom user */} - + - - + + {initials} - - + + {userName} {userEmail && ( - + {userEmail} )} @@ -417,7 +422,7 @@ export function AppLayout({ {/* ── Main ────────────────────────────────────────────────────────────── */} - + {children} diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 9d023a3ec..f66df0afa 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import { ShieldCheck, Train } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -27,67 +28,102 @@ export default function AuthLayout({ left, }: AuthLayoutProps) { return ( -
-
-
-
-
-
-
- -
-
-

EDR Freight

-

- Railway Logistics Platform -

-
-
-
-
- {left.badge} -
-

- {left.title} -

-

- {left.description} -

-
-
+ + + {/* ── Left: branded panel ─────────────────────────────────────── */} + + {/* rail-line motif */} + + {/* corner glow */} + + + {/* Brand */} + + + + + + + EDR Freight + + + Railway Logistics Platform + + + + + {/* Headline + features */} + + + {left.badge} + + + {left.title} + + + {left.description} + + + {left.features.map((item) => ( -
-
+ + -
- {item} -
+
+ + {item} + + ))} -
-
-
-
+ + + {/* spacer keeps brand pinned top / content centered */} + + + + {/* ── Right: form area ────────────────────────────────────────── */} + -
-
-
+ + {/* Mobile brand */} + + -
-
-

EDR Freight

-

+ + + + EDR Freight + + Railway Logistics Platform -

-
-
-
{children}
-
-
-
-
+ + + + {children} + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 1c97a13f9..81ab28011 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -4,6 +4,7 @@ import { format } from "date-fns"; import { useQuery } from "@tanstack/react-query"; import { Anchor, + Avatar, Badge, Box, Button, @@ -30,6 +31,7 @@ import { Plus, Receipt, Train, + TrendingUp, UploadCloud, } from "lucide-react"; @@ -71,6 +73,10 @@ const INVOICE_STATUS_META: Record = { Cancelled: { color: "gray" }, }; +// Card hover-lift, shared via Tailwind utilities. +const LIFT = + "transition-all duration-200 hover:-translate-y-[3px] hover:shadow-[0_16px_34px_-16px_rgba(16,24,40,0.22)] hover:border-emerald-300!"; + export default function MyPortalPage() { const { user, customer } = useAuth(); const myShipments = useMemo(() => getMyShipments(), []); @@ -109,39 +115,30 @@ export default function MyPortalPage() { const paidPct = Math.round((completedInvoices / invoiceTotal) * 100); const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const initials = displayName + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0]?.toUpperCase()) + .join(""); const documentsComplete = !!(customer as any)?.documentsComplete; const hasOutstanding = outstandingInvoices.length > 0; + const today = format(new Date(), "EEEE, MMMM d"); return ( - + {/* ── Document setup notice ───────────────────────────────────── */} {!documentsComplete && !dismissed && ( - - + + - + Finish setting up your account @@ -157,12 +154,7 @@ export default function MyPortalPage() { to="/settings?tab=documents" size="sm" radius="xl" - style={{ - background: "#f59e0b", - color: "white", - fontWeight: 600, - flexShrink: 0, - }} + className="flex-shrink-0 bg-gradient-to-br from-amber-500 to-amber-600! font-semibold text-white!" rightSection={} > Upload docs @@ -170,56 +162,58 @@ export default function MyPortalPage() { )} - {/* ── Welcome (branded band) ──────────────────────────────────── */} - + {/* ── Welcome (branded hero) ──────────────────────────────────── */} + {/* faint rail-line motif */} + - - - Welcome back - - - {displayName} - - + + + + {initials} + + + + + {today} + + + Welcome back, {displayName.split(" ")[0]} + + + Here's what's moving across your account today. + + + @@ -231,13 +225,13 @@ export default function MyPortalPage() { } + icon={} color="blue" /> } + icon={} color="teal" /> } + icon={} color={hasOutstanding ? "red" : "edr-green"} /> } + icon={} color="edr-green" ring={{ value: paidPct, color: "edr-green" }} /> @@ -262,20 +256,31 @@ export default function MyPortalPage() { {/* Recent Bookings (left, wider) */} - + - - Recent Bookings - - Your latest freight requests - - + + + + + + Recent Bookings + + Your latest freight requests + + +
-
- ) : ( -

- Pricing will be calculated after submission. -

- )} - + + + + + + + + + Draft Booking Request + + + {booking.reference} + + + + · + + Created {format(new Date(booking.createdAt), "MMM d, yyyy")} + + + + + + + + + - - - - - Required Documents - - - Provide the necessary documents for this booking. Some information - is pre-filled from your company profile. - - - - {docError && ( -
- -

{docError}

-
- )} -
-

- - Company Information (from profile) -

-
- - - - -
-

- To update your company information, go to{" "} - + + + {/* Step 2 */} + 0 + ? "border-amber-200 bg-amber-50/30" + : "border-gray-200 bg-white" + }`} + > + + 0 ? "bg-amber-500" : "bg-gray-300" + }`} > - Settings - - . -

-
+ {allDocsUploaded ? : 2} +
+ 0 ? "orange.7" : "dimmed"} + > + Step 2 + + + Upload Documents + + {allDocsUploaded + ? "All 4 documents uploaded." + : `${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} documents uploaded.`} + + {!allDocsUploaded && ( + + )} + - + {/* Step 3 */} + + + + 3 + + Step 3 + + Submit Request + + Send your booking to EDR staff for review and approval. + + + + + -
-

- - Upload Booking Documents -

-
+ {/* ── Main grid ────────────────────────────────────────────────── */} + + {/* Left: Documents */} + + + + + + + Required Documents + + + All 4 documents are required before you can submit. + + + {allDocsUploaded ? ( + }> + All uploaded + + ) : ( + + {uploadedCount}/{REQUIRED_DOC_FIELDS.length} uploaded + + )} + + + {docError && ( + } mb="md"> + {docError} + + )} + + {/* Company info */} + + + + + Company Info (pre-filled from profile) + + + + + + + + + + Update in{" "} + navigate("/settings")}> + Settings + + + + + + + {/* Document slots */} + {REQUIRED_DOC_FIELDS.map((doc) => { const isUploaded = uploadedCodes.has(doc.key); + const selectedFile = selectedFiles[doc.key]; return ( -
- -
- {isUploaded ? ( - - - Uploaded - - ) : ( - <> + + + + {isUploaded ? : } + + + {doc.label} + {isUploaded && ( + Uploaded ✓ + )} + {selectedFile && !isUploaded && ( + {selectedFile.name} + )} + {!isUploaded && !selectedFile && ( + Required · Not yet uploaded + )} + + + {!isUploaded && ( + { - fileInputRefs.current[doc.key] = el; - }} + ref={(el) => { fileInputRefs.current[doc.key] = el; }} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" - onChange={(e) => { - handleFileSelect( - doc.key, - e.target.files?.[0] ?? null, - ); - }} + onChange={(e) => handleFileSelect(doc.key, e.target.files?.[0] ?? null)} /> - - {selectedFiles[doc.key] && ( - + + )} - + )} -
-
+ + ); })} -
+ -
- - {uploadMutation.isSuccess && ( -

- - Documents uploaded successfully -

- )} -
-
- - - - - - - - Cancel Booking - - - If you no longer need this booking, you can cancel it. - - - -

- Cancelling will terminate this booking request and cannot be - undone. -

- - - - - - - Cancel Booking - - Are you sure you want to cancel this booking? This action - cannot be undone. - - -
- - setCancelReason(e.target.value)} - autoFocus - /> -
- - - - + {anyFileSelected && ( + - -
-
-
+ {uploadMutation.isSuccess && ( + + + Documents uploaded successfully + + )} + + )} +
+ + + {/* Right: Pricing + Booking summary */} + + + {/* Pricing */} + + + + Pricing Estimate + + + Estimated cost based on your current booking details. + + {pricing ? ( + + ) : ( + + + Pricing will be calculated automatically. + + + )} + + + {/* Booking summary */} + + + + Booking Summary + + + + + Route + + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} + + + + {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + + + + + + + + + + + + + + + + {/* ── Cancel zone ──────────────────────────────────────────────── */} + + + + Danger Zone + + + Cancelling this booking is permanent and cannot be undone. + + - - + + {/* Cancel modal */} + setCancelDialogOpen(false)} + title={Cancel Booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference}? This action + cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); } +// ─── Readonly View ──────────────────────────────────────────────────────────── + function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); - const queryClient = useQueryClient(); const payMutation = useMutation({ mutationFn: () => api.bookings.pay.call({ id: booking.id }), onSuccess: (data) => { - if (data.redirectUrl) { - window.location.href = data.redirectUrl; - } + if (data.redirectUrl) window.location.href = data.redirectUrl; }, }); const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; - const pricing = booking.pricingBreakdown; - const uploadedCodes = useMemo( - () => new Set(booking.files?.map((f) => f.code) ?? []), - [booking.files], - ); - return ( -
-
+ + - - -
-
- -
-
-
-

- {booking.reference} -

+ {/* ── Hero ─────────────────────────────────────────────────────── */} + + + + + + + + + + {booking.freightType === "CONTAINER" ? "Container" : "Bulk"} ·{" "} + {booking.tradeDirection ?? "Booking"} + + + {booking.reference} + + -
-
- - + · + + {format( new Date(booking.scheduledDate ?? booking.createdAt), - "MMM d, yyyy HH:mm", + "MMM d, yyyy", )} - -
-
- {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( - - )} -
-
+ + +
+ + {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( + + )} + - {renderContractCard(booking, navigate, payMutation)} + {/* ── Contract card ────────────────────────────────────────────── */} + {renderContractCard(booking, navigate)} - {pricing && ( - - - - - Pricing Breakdown - - - -
- - - - - - - - - {pricing.lineItems.map((item, i) => ( - - - - - ))} - - - - - -
DescriptionAmount
{item.description} - {item.amount.toLocaleString()} {item.currency} -
Total Estimated Cost - {pricing.totalAmount.toLocaleString()} {pricing.currency} -
-
-
-
- )} + {/* ── Progress & status ────────────────────────────────────────── */} + + + + Booking Progress + - {booking.files && booking.files.length > 0 && ( - - - - - Uploaded Documents ({booking.files.length}) - - - - - - - )} - - - - - - Booking Status Lifecycle - - - Track the journey from request to completion - - - -
-
-
= 0 - ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` - : "0%", - }} - /> -
- - {PROGRESS_STAGES.map((stage, idx) => { - const isCompleted = idx < currentStageIndex; - const isActive = idx === currentStageIndex; - - return ( -
: } + + -
- {isCompleted ? ( - - ) : ( - - )} -
- - {stage.label} - -
- ); - })} -
+ {stage.label} + + + ); + })} + -
-
- {normalizedStatus === "CANCELLED" ? ( - + {/* Current status banner */} + + + + {normalizedStatus === "CANCELLED" || normalizedStatus === "REJECTED" ? ( + ) : ( - + )} -
-
-

+ + {statusConfig.title} -

-

+ + {statusConfig.description} -

-
+ + {normalizedStatus !== "CANCELLED" && - normalizedStatus !== "DELIVERED" && ( -
-
-

- Est. Waiting -

-

- 1-2 Working Days -

-
- -
+ normalizedStatus !== "DELIVERED" && + normalizedStatus !== "COMPLETED" && ( + + Est. Waiting + 1–2 Working Days + )} -
- + + -
-
- - - - - Route & Service - - - -
- } - /> -
-
- - -
- + {/* ── Route + Cargo (2 col) ─────────────────────────────────────── */} + + + + + + Route & Service + + + {/* Origin → Destination */} + + + + + Origin + + + + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} + + + + + + + + Rail -
- } - /> -
+ + + + Destination + + + + + + {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + + + + -
- } - label="Service" - value={ - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail & Forwarding" - : "Rail Only" - } - /> - } - label="Return" - value={ - booking.equipmentReturn === "WITH_RETURN" - ? "With Return" - : "Without Return" - } - /> - } - label="Trade" - value={ - booking.tradeDirection === "IMPORT" ? "Import" : "Export" - } - /> -
-
+ + } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> + } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> + } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} /> +
+ - - - - - Mile Services - - - -
-

+ + + + + Cargo Specifications + + + + } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> + } label="Total Weight" value={`${booking.cargoTotalWeightVgm} t`} /> + } label="Currency" value={booking.paymentCurrency} /> + } label="Hazardous" value={booking.isHazardous ? "Yes" : "No"} /> + + + {booking.containers && booking.containers.length > 0 && ( + <> + + + Load Details + + + + + + Type + Qty + VGM + + + + {booking.containers.map((c, i) => ( + + {c.type} + {c.qty} + {c.vgm}t + + ))} + +
+
+ + )} +
+
+ + + {/* ── Mile services + Contract info ─────────────────────────────── */} + + + + + + Mile Services + + + + First Mile -

- -
-
-

+ + + {booking.firstMileEnabled && booking.firstMilePickupAddress + ? booking.firstMilePickupAddress + : "Not requested"} + + + + Last Mile -

-

+ + {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} -

-
-
+ + +
+ - - - - - Cargo Specifications - - - -
- } - label="Freight Type" - value={ - booking.freightType === "BULK" ? "Bulk" : "Break Bulk" - } - /> - } - label="Weight (VGM)" - value={`${booking.cargoTotalWeightVgm} Tons`} - /> - } - label="Currency" - value={booking.paymentCurrency} - /> -
- - {booking.containers && booking.containers.length > 0 && ( - <> - -
-

- Load Details -

-
- - - - - - - - - - {booking.containers.map((c, i) => ( - - - - - - ))} - -
Type - Quantity - - VGM (Tons) -
- {c.type} - - {c.qty} Units - - {c.vgm}t -
-
-
- - )} -
-
-
- -
- - - - - Contract Info - - - - - - -
- + + + + + Contract Info + + + + + + + Hazardous: {booking.isHazardous ? "Yes" : "No"} - + Refrigerated: {booking.isRefrigerated ? "Yes" : "No"} -
-
+ +
+ + - - - Additional Info - - - {booking.freightSubtype && ( -
-

- Cargo Description -

-

- "{booking.freightSubtype}" -

-
- )} - {booking.financialTerms && ( - <> - -
-

- Financial Terms -

-
-

- - {booking.financialTerms} -

-
-
- - )} - {!booking.freightSubtype && !booking.financialTerms && ( -

- No additional information provided. -

- )} -
-
-
-
-
-
+ {/* ── Pricing + Documents ───────────────────────────────────────── */} + {(pricing || (booking.files && booking.files.length > 0)) && ( + + {pricing && ( + + + + + Pricing Breakdown + + + + + )} + {booking.files && booking.files.length > 0 && ( + + + + + Uploaded Documents ({booking.files.length}) + + + {booking.files.map((file) => ( + + + + + + {file.name} + {file.code.replace(/_/g, " ")} + + + ))} + + + + )} + + )} + + {/* ── Additional info ───────────────────────────────────────────── */} + {(booking.freightSubtype || booking.financialTerms) && ( + + Additional Information + + {booking.freightSubtype && ( + + + Cargo Description + + "{booking.freightSubtype}" + + )} + {booking.financialTerms && ( + <> + {booking.freightSubtype && } + + + Financial Terms + + + + + {booking.financialTerms} + + + + + )} + + + )} + + ); } +// ─── Contract card ──────────────────────────────────────────────────────────── + function renderContractCard( booking: Freight.IBooking, navigate: ReturnType, - payMutation: { mutate: () => void; isPending: boolean }, ) { const s = booking.status; if ( @@ -1283,145 +1178,177 @@ function renderContractCard( return null; } - const config: Record< - string, - { title: string; description: string; buttonLabel?: string } - > = { + const config: Record = { APPROVED_PENDING_SIGNATURE: { title: "Contract being prepared", - description: - "Your booking has been approved. The contract is being generated and will be available shortly.", + description: "Your booking has been approved. The contract will be available shortly.", }, CONTRACT_READY: { - title: "Contract ready for signature", - description: - "Review the agreement and apply your digital signature.", - buttonLabel: "View & sign contract", + title: "Action required — sign your contract", + description: "Your contract is ready. Review the agreement and apply your digital signature to proceed.", + buttonLabel: "View & Sign Contract", + urgent: true, }, SIGNED_CUSTOMER: { title: "You have signed the contract", - description: - "Your signature has been submitted. Awaiting staff signature to finalize.", - buttonLabel: "View contract", + description: "Your signature has been submitted. Awaiting the final staff signature.", + buttonLabel: "View Contract", }, FULLY_EXECUTED: { title: "Contract fully executed", - description: - "The contract has been fully signed and executed by all parties.", - buttonLabel: "View contract", + description: "The contract has been signed by all parties. You can now proceed to payment.", + buttonLabel: "View Contract", }, }; const c = config[s]; + const isUrgent = c.urgent; return ( - -
-

{c.title}

-

{c.description}

-
+ + + + + + + + {c.title} + + + {c.description} + + + {c.buttonLabel && ( - + )} -
+
); } -function RouteEndpoint({ - label, - station, - icon, +// ─── Shared sub-components ──────────────────────────────────────────────────── + +function PricingTable({ + pricing, }: { - label: string; - station: string; - icon: React.ReactNode; + pricing: { + lineItems: { description: string; amount: number; currency: string }[]; + totalAmount: number; + currency: string; + }; }) { return ( -
-
- {icon &&
{icon}
} -
-
-

- {label} -

-

{station}

-
-
+ + + + + Description + Amount + + + + {pricing.lineItems.map((item, i) => ( + + {item.description} + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + Total Estimated Cost + + {pricing.totalAmount.toLocaleString()} {pricing.currency} + + + +
+
); } -function InfoItem({ - icon, +function MiniInfo({ label, value, + icon, }: { - icon?: React.ReactNode; label: string; value?: string | number | null; + icon?: React.ReactNode; }) { return ( -
- {icon && ( -
- {icon} -
- )} -
-

+ + {icon ? ( + + {icon} + + {label} + + + ) : ( + {label} -

-

{value ?? "—"}

-
-
+ + )} + {value ?? "—"} + ); } function StatusBadge({ status }: { status: string }) { - const statusColors: Record = { - DRAFT: "bg-muted text-muted-foreground border-border", - CHANGES_REQUESTED: "bg-amber-50 text-amber-700 border-amber-200", - SUBMITTED: "bg-primary/10 text-primary border-primary/20", - PENDING_APPROVAL: "bg-primary/10 text-primary border-primary/20", - APPROVED_PENDING_SIGNATURE: "bg-primary/10 text-primary border-primary/20", - APPROVED: "bg-primary/10 text-primary border-primary/20", - CONTRACT_READY: "bg-primary/10 text-primary border-primary/20", - SIGNED_CUSTOMER: "bg-primary/10 text-primary border-primary/20", - FULLY_EXECUTED: "bg-primary/10 text-primary border-primary/20", - PNR_GENERATED: "bg-primary/10 text-primary border-primary/20", - PAYMENT_VERIFICATION_IN_PROGRESS: "bg-primary/10 text-primary border-primary/20", - PAID: "bg-primary/10 text-primary border-primary/20", - CONFIRMED: "bg-primary/10 text-primary border-primary/20", - IN_TRANSIT: "bg-primary/10 text-primary border-primary/20", - PENDING_CONSOLIDATION: "bg-primary/10 text-primary border-primary/20", - CONSOLIDATED: "bg-primary/10 text-primary border-primary/20", - COMPLETED: "bg-muted text-foreground border-border", - DELIVERED: "bg-muted text-foreground border-border", - REJECTED: "bg-destructive/10 text-destructive border-destructive/20", - CANCELLED: "bg-destructive/10 text-destructive border-destructive/20", + const colorMap: Record = { + DRAFT: "gray", + CHANGES_REQUESTED: "yellow", + SUBMITTED: "edr-green", + PENDING_APPROVAL: "edr-green", + APPROVED_PENDING_SIGNATURE: "edr-green", + APPROVED: "edr-green", + CONTRACT_READY: "edr-green", + SIGNED_CUSTOMER: "edr-green", + FULLY_EXECUTED: "edr-green", + PNR_GENERATED: "edr-green", + PAYMENT_VERIFICATION_IN_PROGRESS: "edr-green", + PAID: "edr-green", + CONFIRMED: "edr-green", + IN_TRANSIT: "edr-green", + PENDING_CONSOLIDATION: "edr-green", + CONSOLIDATED: "edr-green", + COMPLETED: "gray", + DELIVERED: "gray", + REJECTED: "red", + CANCELLED: "red", }; return ( {status.replace(/_/g, " ")} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index b72d18ee1..543817f92 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -41,11 +41,7 @@ import { type BookingFormValues, type RouteDirection, } from "./new-booking-form/schema"; -import { - SelectField, - SelectItem, - AlertBox, -} from "./new-booking-form/shared"; +import { SelectField, AlertBox } from "./new-booking-form/shared"; function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { return yard?.label ?? yard?.name ?? yard?.code ?? ""; @@ -399,10 +395,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Contract Type *" placeholder="Select contract type..." - > - New Contract - Contract Renewal - + data={[ + { value: "new", label: "New Contract" }, + { value: "renewal", label: "Contract Renewal" }, + ]} + /> )} /> @@ -431,10 +428,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Service Type *" placeholder="Select service type..." - > - Rail Transport Only - Logistics (Rail + Forwarding) - + data={[ + { value: "rail", label: "Rail Transport Only" }, + { value: "rail_forwarding", label: "Logistics (Rail + Forwarding)" }, + ]} + /> )} /> @@ -447,10 +445,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Equipment Return" placeholder="Select..." - > - With Return - Without Return - + data={[ + { value: "with_return", label: "With Return" }, + { value: "without_return", label: "Without Return" }, + ]} + /> )} />
@@ -602,19 +601,8 @@ export default function EditBookingPage() { label="Origin Yard *" placeholder="Select origin..." disabled={yardOptions.length === 0} - > - {yardOptions.length === 0 ? ( - No yards available - ) : ( - yardOptions - .filter((y) => y.value !== destinationYard) - .map((y) => ( - - {y.label} - - )) - )} - + data={yardOptions.filter((y) => y.value !== destinationYard)} + /> )} /> @@ -628,19 +616,8 @@ export default function EditBookingPage() { label="Destination Yard *" placeholder="Select destination..." disabled={yardOptions.length === 0} - > - {yardOptions.length === 0 ? ( - No yards available - ) : ( - yardOptions - .filter((y) => y.value !== originYard) - .map((y) => ( - - {y.label} - - )) - )} - + data={yardOptions.filter((y) => y.value !== originYard)} + /> )} /> @@ -664,13 +641,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Shipping Line" placeholder="Select shipping line..." - > - {shippingLineOptions.map((sl) => ( - - {sl.label} - - ))} - + data={shippingLineOptions} + /> )} /> )} @@ -736,10 +708,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Cargo Type *" placeholder="Select cargo type..." - > - Containerized - General Cargo - + data={[ + { value: "container", label: "Containerized" }, + { value: "bulk", label: "General Cargo" }, + ]} + /> )} /> @@ -782,13 +755,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Freight Type *" placeholder="Select freight type..." - > - {freightTypeGroups.map((group) => ( - - {group.name} - - ))} - + data={freightTypeGroups.map((g) => ({ + value: g.code.toLowerCase(), + label: g.name, + }))} + /> )} /> @@ -802,13 +773,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Commodity *" placeholder="Select commodity..." - > - {commodityOptions.map((option) => ( - - {option} - - ))} - + data={commodityOptions} + /> )} /> )} @@ -903,10 +869,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Size *" placeholder="Size..." - > - 20ft (TEU) - 40ft (FEU) - + data={[ + { value: "20ft", label: "20ft (TEU)" }, + { value: "40ft", label: "40ft (FEU)" }, + ]} + /> )} /> @@ -919,13 +886,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Type *" placeholder="Type..." - > - {containerTypeOptions.map((option) => ( - - {option} - - ))} - + data={containerTypeOptions} + /> )} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index ac0b3661f..3c863eb3d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,6 +1,21 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Group, + Menu, + SimpleGrid, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; import { ArrowRight, Clock, @@ -20,17 +35,6 @@ import { DataTableFooter, type ColumnDef, usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, } from "@edr/ui-common"; export default function MyBookings() { @@ -80,15 +84,19 @@ export default function MyBookings() { cell: ({ row }) => { const booking = row.original; return ( -
-
+ + -
-
-

{booking.reference}

-

{booking.scheduledDate ?? booking.createdAt}

-
-
+ + + + {booking.reference} + + + {booking.scheduledDate ?? booking.createdAt} + + + ); }, }, @@ -96,11 +104,15 @@ export default function MyBookings() { id: "route", header: "Route", cell: ({ row }) => ( -
- {row.original.originYard?.label ?? row.original.originYard?.code ?? "—"} - - {row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"} -
+ + + {row.original.originYard?.label ?? row.original.originYard?.code ?? "—"} + + + + {row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"} + + ), }, { @@ -111,12 +123,15 @@ export default function MyBookings() { const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; const containerType = b.containers?.[0]?.type ?? null; return ( -
-

{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}

-

- {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t -

-
+ + + {b.freightType === "BULK" ? "Bulk" : "Break Bulk"} + + + {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""} + {b.cargoTotalWeightVgm}t + + ); }, }, @@ -124,9 +139,9 @@ export default function MyBookings() { id: "transportMode", header: "Transport", cell: ({ row }) => ( - + {row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"} - + ), }, { @@ -140,26 +155,23 @@ export default function MyBookings() { cell: ({ row }) => { const booking = row.original; return ( -
e.stopPropagation()} - > - - - - - - e.stopPropagation()}> + + + + + + + + } onClick={() => navigate(`/bookings/${booking.id}`)} > - View - - - -
+ + + + ); }, }, @@ -168,148 +180,191 @@ export default function MyBookings() { const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; return ( -
-
- -
-

- My Bookings -

-

- View and manage your freight booking requests. -

-
+ + + {/* ── Header band ─────────────────────────────────────────── */} + + + + + + My Bookings + + + View and manage your freight booking requests. + + -
-
- - + setSearchTerm(e.target.value)} - className="pl-8!" + onChange={(e) => setSearchTerm(e.currentTarget.value)} + leftSection={} + radius="md" + className="w-full sm:w-80" + styles={{ input: { background: "white" } }} /> -
- - - - -
+
+
-
- - -
-

Total Bookings

-

- {bookings.length} -

-
-
- -
-
-
+ {/* ── Stat cards ──────────────────────────────────────────── */} + + } + gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30" + /> + } + gradient="from-sky-500 to-blue-600 shadow-sky-500/30" + /> + } + gradient="from-amber-400 to-orange-500 shadow-amber-500/30" + /> + - - -
-

Active Bookings

-

- {activeCount} -

-
-
- -
-
-
- - - -
-

Pending Approval

-

- {pendingCount} -

-
-
- -
-
-
-
- - - -
- Recent Requests - + {/* ── Table ───────────────────────────────────────────────── */} + + + + Recent Requests + A list of your recent freight bookings and their statuses. - -
- - -
+ - - {total === 0 && dataTableStatus === "success" ? ( -
- -

No bookings found

-

- {searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."} -

-
- ) : ( - navigate(`/bookings/${(row as Freight.IBooking).id}`)} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - )} -
+ {total === 0 && dataTableStatus === "success" ? ( + + + + + + No bookings found + + + {searchTerm + ? "No bookings match your current search filter." + : "You haven't requested any bookings yet."} + + {!searchTerm && ( + + )} + + ) : ( + navigate(`/bookings/${(row as Freight.IBooking).id}`)} + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount: pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-0 shadow-none" + footer={DataTableFooter} + /> + )}
-
-
+ + + ); +} + +function StatCard({ + label, + value, + icon, + gradient, +}: { + label: string; + value: number; + icon: React.ReactNode; + gradient: string; +}) { + return ( + + + + + {label} + + + {value} + + + + {icon} + + + ); } function StatusBadge({ status }: { status: string }) { - const styles: Record = { - DRAFT: "bg-amber-100 text-amber-700", - CONFIRMED: "bg-primary/10 text-primary", - IN_TRANSIT: "bg-muted text-foreground", - DELIVERED: "bg-primary/10 text-primary", - CANCELLED: "bg-destructive/10 text-destructive", + const colorMap: Record = { + DRAFT: "amber", + CONFIRMED: "edr-green", + IN_TRANSIT: "blue", + DELIVERED: "edr-green", + CANCELLED: "red", }; return ( - - {status.replace(/_/g, ' ')} - + {status.replace(/_/g, " ")} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 78ba91af6..eb088c883 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -3,14 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { - AlertCircle, - Check, - ChevronLeft, - ChevronRight, - LoaderCircle, -} from "lucide-react"; -import { Button } from "@edr/ui-common"; +import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react"; +import { Alert, Box, Button, Text } from "@mantine/core"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { @@ -210,27 +204,34 @@ export default function NewBookingPage() { className="flex flex-col" onSubmit={handleSubmit} > -
-
+ {/* Step indicator — sticky */} + + -
-
+ + -
-
+ {/* Step content */} + + {createMutation.isError && ( -
- -
-

Failed to save draft

-

- {createMutation.error instanceof Error - ? createMutation.error.message - : "An unexpected error occurred. Please try again."} -

-
-
+ } + radius="md" + mb="lg" + > + + Failed to save draft + + + {createMutation.error instanceof Error + ? createMutation.error.message + : "An unexpected error occurred. Please try again."} + + )} + {step === 1 && } {step === 2 && } {step === 3 && ( @@ -251,43 +252,49 @@ export default function NewBookingPage() { {step === 5 && ( )} -
-
+ + -
-
+ {/* Navigation footer — sticky */} + + + {step < STEPS.length ? ( - ) : ( )} -
-
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx index 76ef8e30d..d7ca51476 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx @@ -9,19 +9,19 @@ export function StepIndicator({ step }: { step: number }) {
item.id - ? "bg-primary text-primary-foreground" + ? "bg-emerald-600 text-white shadow-sm shadow-emerald-600/40" : step === item.id - ? "border-2 border-primary text-primary" - : "bg-muted text-muted-foreground" + ? "border-2 border-emerald-500 text-emerald-600 shadow-sm shadow-emerald-500/30" + : "bg-gray-100 text-gray-400" }`} > {step > item.id ? : item.id}
= item.id ? "text-foreground" : "text-muted-foreground" + className={`hidden text-[10px] font-medium lg:block transition-colors ${ + step >= item.id ? "text-gray-800" : "text-gray-400" }`} > {item.short} @@ -29,8 +29,8 @@ export function StepIndicator({ step }: { step: number }) {
{index < STEPS.length - 1 && (
item.id ? "bg-primary" : "bg-border" + className={`mx-1 h-0.5 flex-1 rounded-full transition-all duration-300 ${ + step > item.id ? "bg-emerald-500" : "bg-gray-200" }`} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index a731d081c..4d5f8e33a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -1,31 +1,16 @@ import type { ReactNode } from "react"; -import type { - ControllerRenderProps, - FieldError as RhfFieldError, -} from "react-hook-form"; -import { - AlertTriangle, - Check, - CheckCircle2, - Info, - XCircle, -} from "lucide-react"; -import { - Field, - FieldDescription, - FieldError, - FieldLabel, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@edr/ui-common"; -import type { BookingFormInputValues, BookingFormValues } from "./schema"; -import { cn } from "@/lib/utils"; +import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form"; +import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react"; +import { Alert, Select, Text, Title } from "@mantine/core"; +import type { BookingFormInputValues } from "./schema"; export function OptionFieldError({ error }: { error?: { message?: string } }) { - return ; + if (!error?.message) return null; + return ( + + {error.message} + + ); } export function OptionCard({ @@ -44,16 +29,17 @@ export function OptionCard({ type="button" onClick={onClick} disabled={disabled} - className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled - ? "cursor-not-allowed border-border bg-muted opacity-60" + className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${ + disabled + ? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60" : selected - ? "border-primary bg-primary/5" - : "border-border bg-card hover:border-primary/40" - }`} + ? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20" + : "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm" + }`} > {selected && !disabled && ( - - + + )} {children} @@ -68,34 +54,25 @@ export function AlertBox({ tone: "warning" | "error" | "success" | "info"; children: ReactNode; }) { - const styles = { - warning: "bg-amber-50 border-amber-200 text-amber-800", - error: "bg-red-50 border-red-200 text-red-800", - success: "bg-emerald-50 border-emerald-200 text-emerald-800", - info: "bg-sky-50 border-sky-200 text-sky-800", + const map: Record = { + warning: { color: "yellow", icon: }, + error: { color: "red", icon: }, + success: { color: "teal", icon: }, + info: { color: "blue", icon: }, }; - const icons = { - warning: , - error: , - success: , - info: , - }; - + const { color, icon } = map[tone]; return ( -
- {icons[tone]} -
{children}
-
+ + {children} + ); } export function StepLabel({ children }: { children: ReactNode }) { return ( -

+ {children} -

+ ); } @@ -108,8 +85,12 @@ export function StepHeader({ }) { return (
-

{title}

-

{description}

+ + {title} + + + {description} +
); } @@ -120,46 +101,27 @@ export function SelectField({ label, placeholder, disabled, - children, + data, }: { field: ControllerRenderProps; error?: RhfFieldError; label: string; placeholder: string; disabled?: boolean; - children: ReactNode; + data: string[] | { value: string; label: string }[]; }) { return ( - - {label} - - - - ); -} - -export { SelectItem }; - -export function SelectOptions({ options }: { options: readonly string[] }) { - return ( - <> - {options.map((option) => ( - - {option} - - ))} - + - - + )} /> )}
+ {/* Last Mile */}
(
- +
-

- Last Mile - Delivery -

-

+

Last Mile — Delivery

+

Truck delivery from the destination rail yard to the final address (Port to Door).

@@ -194,7 +173,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{ + onChange={(e) => { + const value = e.currentTarget.checked; field.onChange(value); if (!value) { form.setValue("lastMile.deliveryAddress", "", { @@ -206,6 +186,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { }); } }} + color="edr-green" />
)} @@ -215,19 +196,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { name="lastMile.deliveryAddress" control={form.control} render={({ field, fieldState }) => ( - - - - + )} /> )}
+ {/* Equipment Return */} {lastMileEnabled && (
(
-
-
-

Equipment Return

-

- {field.value === "with_return" - ? "Container returned to EDR after unloading." - : "Container retained by the customer after delivery."} -

-
+
+

Equipment Return

+

+ {field.value === "with_return" + ? "Container returned to EDR after unloading." + : "Container retained by the customer after delivery."} +

{ + onChange={(e) => { field.onChange( - value ? "with_return" : "without_return", + e.currentTarget.checked ? "with_return" : "without_return", ); }} + color="edr-green" />
)} @@ -259,6 +239,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)} + {/* Customs Clearing */}
(
- +
-

- Customs Clearing Service -

-

+

Customs Clearing Service

+

EDR handles customs documentation and clearance on your behalf.

@@ -279,7 +258,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
field.onChange(e.currentTarget.checked)} + color="edr-green" />
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index f42e19449..c72d62d79 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { Flame, MapPin, Snowflake } from "lucide-react"; -import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common"; +import { Divider, Skeleton, Stack, Switch } from "@mantine/core"; import type { Freight } from "@edr/types"; import { BookingFormInputValues, @@ -10,11 +10,7 @@ import { } from "./schema"; import { SelectField, StepHeader, StepLabel } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step4Route({ form, @@ -30,26 +26,29 @@ export function Step4Route({ const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; - return referenceData.yard.map((y) => ({ - value: y.name, - label: y.name, - country: y.country, - })); + return referenceData.yard.map((y) => ({ value: y.name, label: y.name })); }, [referenceData]); const shippingLineOptions = useMemo(() => { if (!referenceData?.shipping_line) return []; - return referenceData.shipping_line.map((sl) => ({ - value: sl.name, - label: sl.name, - })); + return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name })); }, [referenceData]); + const originData = useMemo( + () => yardOptions.filter((o) => o.value !== destinationYard), + [yardOptions, destinationYard], + ); + const destData = useMemo( + () => yardOptions.filter((o) => o.value !== originYard), + [yardOptions, originYard], + ); + const direction = getRouteDirection(originYard, destinationYard); + const directionStyle: Record = { export: "bg-sky-50 text-sky-800 border-sky-200", import: "bg-amber-50 text-amber-800 border-amber-200", - domestic: "bg-muted text-muted-foreground border-border", + domestic: "bg-gray-100 text-gray-600 border-gray-200", }; const directionLabel: Record = { export: "Export workflow (inside country to outside country)", @@ -85,15 +84,11 @@ export function Step4Route({ - - + data={originData} + /> )} /> - - + data={destData} + /> )} />
@@ -126,7 +117,7 @@ export function Step4Route({
)} - {direction && direction != "domestic" && ( + {direction && direction !== "domestic" && ( - {shippingLineOptions.map((sl) => ( - - {sl.label} - - ))} - + data={shippingLineOptions} + /> )} /> )} - -
+ + +

Hazardous Material

-

+

Applies a Hazard Surcharge to the final bill.

- + field.onChange(e.currentTarget.checked)} + color="edr-green" + />
)} /> @@ -176,13 +167,17 @@ export function Step4Route({

Refrigerated Cargo

-

+

Temperature-controlled transport applies a Refrigerator Surcharge.

- + field.onChange(e.currentTarget.checked)} + color="edr-green" + />
)} /> @@ -193,48 +188,18 @@ export function Step4Route({ function LoadingSkeleton() { return ( -
+
-
- - -
-
- - -
+ + + + + + + +
- +
); } - -function YardSelectOptions({ - options, - excludeValue, -}: { - options: Array<{ value: string; label: string; country: string }>; - excludeValue: string; -}) { - if (options.length === 0) { - return ( - - No yards available - - ); - } - - const availableOptions = options.filter( - (option) => option.value !== excludeValue, - ); - - return ( - <> - {availableOptions.map((option) => ( - - {option.label} - - ))} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index cbd70f5ac..5f2b428f5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,14 +1,7 @@ import { useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; -import { - Button, - Field, - FieldError, - FieldLabel, - Input, - Skeleton, -} from "@edr/ui-common"; +import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core"; import type { Freight } from "@edr/types"; import { BookingFormInputValues, @@ -19,17 +12,13 @@ import { import { AlertBox, OptionCard, + OptionFieldError, SelectField, - SelectItem, StepHeader, StepLabel, } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step5CargoDetails({ form, @@ -44,7 +33,6 @@ export function Step5CargoDetails({ }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); - const bulkCommoditytype = form.watch("bulkCommoditytype"); const containers = form.watch("containers"); const { fields, append, remove } = useFieldArray({ @@ -61,9 +49,7 @@ export function Step5CargoDetails({ const freightTypeGroups = useMemo(() => { if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.filter( - (g) => g.code !== "CONTAINER", - ); + return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER"); }, [referenceData]); const commodityOptions = useMemo(() => { @@ -97,14 +83,14 @@ export function Step5CargoDetails({ title="Cargo Details" description="Define your cargo type, weight, and container configuration." /> -
- +
+
- - + +
- - + +
); @@ -117,28 +103,27 @@ export function Step5CargoDetails({ description="Define your cargo type, weight, and container configuration." /> + {/* Cargo Type */}
Cargo Type * ( - +
{ field.onChange("container"); - form.setValue("freightType", "", { - shouldDirty: true, - }); + form.setValue("freightType", "", { shouldDirty: true }); }} > -
- +
+

Containerized

-

+

Pre-packed containerized cargo (20ft / 40ft).

@@ -153,45 +138,41 @@ export function Step5CargoDetails({

General Cargo

-

+

Bulk commodities or break-bulk cargo.

- - + +
)} />
+ {/* Weight */}
Weight ( - - - Total Cargo Weight(Tons)* - -
- - -
- -
+ } + error={fieldState.error?.message} + radius="md" + min={0} + step={0.01} + /> )} />
+ + {/* Bulk freight type */} {cargoType === "bulk" && (
Freight Type * @@ -199,7 +180,7 @@ export function Step5CargoDetails({ name="freightType" control={form.control} render={({ field, fieldState }) => ( - +
{freightTypeGroups.map((group) => { const val = group.code.toLowerCase(); @@ -219,36 +200,30 @@ export function Step5CargoDetails({ ); })}
- - + +
)} /> {freightType && commodityOptions.length > 0 && ( -
- ( - - {commodityOptions.map((option) => ( - - {option} - - ))} - - )} - /> -
+ ( + + )} + /> )}
)} + {/* Container list */} {cargoType === "container" && ( <>
@@ -256,18 +231,14 @@ export function Step5CargoDetails({ Containers
@@ -280,25 +251,31 @@ export function Step5CargoDetails({ return (
+ + Container {index + 1} + {fields.length > 1 && ( - + + )}
+ {/* Container size */} ( - +
{[ { @@ -321,52 +298,47 @@ export function Step5CargoDetails({ onClick={() => typeField.onChange(ct.val)} >
- +

{ct.label}

-

- {ct.limit} -

+

{ct.limit}

))}
- - + +
)} /> + {/* Qty + VGM + Type */}
( - - Quantity * +
+ + Quantity * +
- - qtyField.onChange(e.target.value) - } + onChange={(e) => qtyField.onChange(e.target.value)} onBlur={qtyField.onBlur} type="number" - aria-invalid={fieldState.invalid} - className="text-center" - min="1" + min={1} + className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" />
- - + {fieldState.error?.message && ( + + {fieldState.error.message} + + )} +
)} /> @@ -389,20 +365,18 @@ export function Step5CargoDetails({ name={`containers.${index}.vgm`} control={form.control} render={({ field: vgmField, fieldState }) => ( - - Tons* - vgmField.onChange(e.target.value)} - onBlur={vgmField.onBlur} - type="number" - aria-invalid={fieldState.invalid} - placeholder="e.g. 18.5" - min="0" - step="0.1" - /> - - + vgmField.onChange(e.target.value)} + onBlur={vgmField.onBlur} + type="number" + label="Tons *" + placeholder="e.g. 18.5" + error={fieldState.error?.message} + radius="md" + min={0} + step={0.1} + /> )} /> @@ -415,13 +389,8 @@ export function Step5CargoDetails({ error={fieldState.error} label="Container Type *" placeholder="Select type..." - > - {containerTypeOptions.map((option) => ( - - {option} - - ))} - + data={containerTypeOptions} + /> )} />
@@ -441,18 +410,13 @@ export function Step5CargoDetails({ if (result.hasOddUnit) { return ( -
-
-

Unpaired 20ft Container

-

- One 20ft container occupies only half a wagon. The wagon - will depart once a co-loader is found to fill the - remaining slot, which{" "} - may delay departure beyond the standard - lead time. -

-
-
+

Unpaired 20ft Container

+

+ One 20ft container occupies only half a wagon. The wagon + will depart once a co-loader is found to fill the remaining + slot, which may delay departure beyond the + standard lead time. +

); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 2c1b2e4d0..3d2e087db 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -1,15 +1,5 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { Check } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Field, - FieldError, - FieldLabel, - Textarea, -} from "@edr/ui-common"; +import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core"; import { BookingFormInputValues, type BookingFormValues, @@ -17,11 +7,7 @@ import { } from "./schema"; import { StepHeader } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step8Review({ form, @@ -47,13 +33,17 @@ export function Step8Review({ return (
-

{label}

-

{value || "-"}

+ + {label} + + + {value || "—"} +
@@ -64,26 +54,53 @@ export function Step8Review({ const containerSummary = values.cargoType === "container" && values.containers.length > 0 ? values.containers - .filter((c) => +c.qty > 0) - .map((c) => `${c.qty} × ${c.type}`) - .join(", ") + .filter((c) => +c.qty > 0) + .map((c) => `${c.qty} × ${c.type}`) + .join(", ") : ""; + const totalVgm = values.cargoType === "container" ? values.containers.reduce( - (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), - 0, - ) + (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), + 0, + ) : 0; const cargoValue = values.cargoType === "container" ? containerSummary : values.freightType === "bulk" - ? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}` + ? `Bulk — ${values.bulkCommoditytype}` : values.freightType === "break_bulk" - ? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}` + ? `Break-Bulk` : ""; + + function ReviewCard({ + title, + children, + }: { + title: string; + children: React.ReactNode; + }) { + return ( + + + + {title} + + + + {children} + + + ); + } + return (
-
- - - - Contract & Service - - - - - - - - - - - - First & Last Mile - - - - - - - - - - - - - - Route & Cargo - - - - ${values.destinationYard}`} - target={3} - /> - + + + - - - - - + } + target={2} + /> + - - - - Container & Wagons - - - - - 0 ? `${totalVgm.toFixed(1)} tons` : ""} - target={4} - /> - - -
+ + + + + + + + + + + + + + + + + + 0 ? `${totalVgm.toFixed(1)} tons` : ""} + target={4} + /> + + ( - - Additional Notes - ",E.noCloneChecked=!!t.cloneNode(!0).lastChild.defaultValue,t.innerHTML="",E.option=!!t.lastChild})();var We={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};We.tbody=We.tfoot=We.colgroup=We.caption=We.thead,We.th=We.td,E.option||(We.optgroup=We.option=[1,""]);function Ie(e,t){var i;return typeof e.getElementsByTagName<"u"?i=e.getElementsByTagName(t||"*"):typeof e.querySelectorAll<"u"?i=e.querySelectorAll(t||"*"):i=[],t===void 0||t&&ae(e,t)?u.merge([e],i):i}function Kt(e,t){for(var i=0,l=e.length;i-1){f&&f.push(h);continue}if(w=pt(h),x=Ie(I.appendChild(h),"script"),w&&Kt(x),i)for(R=0;h=x[R++];)br.test(h.type||"")&&i.push(h)}return I}var Dr=/^([^.]*)(?:\.(.+)|)/;function xt(){return!0}function gt(){return!1}function Qt(e,t,i,l,f,h){var x,b;if(typeof t=="object"){typeof i!="string"&&(l=l||i,i=void 0);for(b in t)Qt(e,b,i,l,t[b],h);return e}if(l==null&&f==null?(f=i,l=i=void 0):f==null&&(typeof i=="string"?(f=l,l=void 0):(f=l,l=i,i=void 0)),f===!1)f=gt;else if(!f)return e;return h===1&&(x=f,f=function(v){return u().off(v),x.apply(this,arguments)},f.guid=x.guid||(x.guid=u.guid++)),e.each(function(){u.event.add(this,t,f,l,i)})}u.event={global:{},add:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.get(e);if(Be(e))for(i.handler&&(h=i,i=h.handler,f=h.selector),f&&u.find.matchesSelector(ut,f),i.guid||(i.guid=u.guid++),(v=oe.events)||(v=oe.events=Object.create(null)),(x=oe.handle)||(x=oe.handle=function(Se){return typeof u<"u"&&u.event.triggered!==Se.type?u.event.dispatch.apply(e,arguments):void 0}),t=(t||"").match(Ne)||[""],w=t.length;w--;)b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),F&&(I=u.event.special[F]||{},F=(f?I.delegateType:I.bindType)||F,I=u.event.special[F]||{},R=u.extend({type:F,origType:de,data:l,handler:i,guid:i.guid,selector:f,needsContext:f&&u.expr.match.needsContext.test(f),namespace:ne.join(".")},h),(C=v[F])||(C=v[F]=[],C.delegateCount=0,(!I.setup||I.setup.call(e,l,ne,x)===!1)&&e.addEventListener&&e.addEventListener(F,x)),I.add&&(I.add.call(e,R),R.handler.guid||(R.handler.guid=i.guid)),f?C.splice(C.delegateCount++,0,R):C.push(R),u.event.global[F]=!0)},remove:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.hasData(e)&&V.get(e);if(!(!oe||!(v=oe.events))){for(t=(t||"").match(Ne)||[""],w=t.length;w--;){if(b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),!F){for(F in v)u.event.remove(e,F+t[w],i,l,!0);continue}for(I=u.event.special[F]||{},F=(l?I.delegateType:I.bindType)||F,C=v[F]||[],b=b[2]&&new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"),x=h=C.length;h--;)R=C[h],(f||de===R.origType)&&(!i||i.guid===R.guid)&&(!b||b.test(R.namespace))&&(!l||l===R.selector||l==="**"&&R.selector)&&(C.splice(h,1),R.selector&&C.delegateCount--,I.remove&&I.remove.call(e,R));x&&!C.length&&((!I.teardown||I.teardown.call(e,ne,oe.handle)===!1)&&u.removeEvent(e,F,oe.handle),delete v[F])}u.isEmptyObject(v)&&V.remove(e,"handle events")}},dispatch:function(e){var t,i,l,f,h,x,b=new Array(arguments.length),v=u.event.fix(e),w=(V.get(this,"events")||Object.create(null))[v.type]||[],R=u.event.special[v.type]||{};for(b[0]=v,t=1;t=1)){for(;w!==this;w=w.parentNode||this)if(w.nodeType===1&&!(e.type==="click"&&w.disabled===!0)){for(h=[],x={},i=0;i-1:u.find(f,this,null,[w]).length),x[f]&&h.push(l);h.length&&b.push({elem:w,handlers:h})}}return w=this,v\s*$/g;function kr(e,t){return ae(e,"table")&&ae(t.nodeType!==11?t:t.firstChild,"tr")&&u(e).children("tbody")[0]||e}function nn(e){return e.type=(e.getAttribute("type")!==null)+"/"+e.type,e}function an(e){return(e.type||"").slice(0,5)==="true/"?e.type=e.type.slice(5):e.removeAttribute("type"),e}function wr(e,t){var i,l,f,h,x,b,v;if(t.nodeType===1){if(V.hasData(e)&&(h=V.get(e),v=h.events,v)){V.remove(t,"handle events");for(f in v)for(i=0,l=v[f].length;i1&&typeof F=="string"&&!E.checkClone&&tn.test(F))return e.each(function(de){var oe=e.eq(de);ne&&(t[0]=F.call(this,de,oe.html())),yt(oe,t,i,l)});if(I&&(f=jr(t,e[0].ownerDocument,!1,e,l),h=f.firstChild,f.childNodes.length===1&&(f=h),h||l)){for(x=u?.map(Ie(f,"script"),nn),b=x.length;R0&&Kt(x,!v&&Ie(e,"script")),b},cleanData:function(e){for(var t,i,l,f=u.event.special,h=0;(i=e[h])!==void 0;h++)if(Be(i)){if(t=i[V.expando]){if(t.events)for(l in t.events)f[l]?u.event.remove(i,l):u.removeEvent(i,l,t.handle);i[V.expando]=void 0}i[_e.expando]&&(i[_e.expando]=void 0)}}}),u.fn.extend({detach:function(e){return Er(this,e,!0)},remove:function(e){return Er(this,e)},text:function(e){return xe(this,function(t){return t===void 0?u.text(this):this.empty().each(function(){(this.nodeType===1||this.nodeType===11||this.nodeType===9)&&(this.textContent=t)})},null,e,arguments.length)},append:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.appendChild(e)}})},prepend:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;(e=this[t])!=null;t++)e.nodeType===1&&(u.cleanData(Ie(e,!1)),e.textContent="");return this},clone:function(e,t){return e=e??!1,t=t??e,this?.map(function(){return u.clone(this,e,t)})},html:function(e){return xe(this,function(t){var i=this[0]||{},l=0,f=this.length;if(t===void 0&&i.nodeType===1)return i.innerHTML;if(typeof t=="string"&&!en.test(t)&&!We[(vr.exec(t)||["",""])[1].toLowerCase()]){t=u.htmlPrefilter(t);try{for(;l=0&&(v+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-h-v-b-.5))||0),v+w}function Or(e,t,i){var l=Ht(e),f=!E.boxSizingReliable()||i,h=f&&u.css(e,"boxSizing",!1,l)==="border-box",x=h,b=St(e,t,l),v="offset"+t[0].toUpperCase()+t.slice(1);if(Gt.test(b)){if(!i)return b;b="auto"}return(!E.boxSizingReliable()&&h||!E.reliableTrDimensions()&&ae(e,"tr")||b==="auto"||!parseFloat(b)&&u.css(e,"display",!1,l)==="inline")&&e.getClientRects().length&&(h=u.css(e,"boxSizing",!1,l)==="border-box",x=v in e,x&&(b=e[v])),b=parseFloat(b)||0,b+Zt(e,t,i||(h?"border":"content"),x,l,b)+"px"}u.extend({cssHooks:{opacity:{get:function(e,t){if(t){var i=St(e,"opacity");return i===""?"1":i}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(e,t,i,l){if(!(!e||e.nodeType===3||e.nodeType===8||!e.style)){var f,h,x,b=je(t),v=Xt.test(t),w=e.style;if(v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],i!==void 0){if(h=typeof i,h==="string"&&(f=Nt.exec(i))&&f[1]&&(i=gr(e,t,f),h="number"),i==null||i!==i)return;h==="number"&&!v&&(i+=f&&f[3]||(u.cssNumber[b]?"":"px")),!E.clearCloneStyle&&i===""&&t.indexOf("background")===0&&(w[t]="inherit"),(!x||!("set"in x)||(i=x.set(e,i,l))!==void 0)&&(v?w.setProperty(t,i):w[t]=i)}else return x&&"get"in x&&(f=x.get(e,!1,l))!==void 0?f:w[t]}},css:function(e,t,i,l){var f,h,x,b=je(t),v=Xt.test(t);return v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],x&&"get"in x&&(f=x.get(e,!0,i)),f===void 0&&(f=St(e,t,l)),f==="normal"&&t in Mr&&(f=Mr[t]),i===""||i?(h=parseFloat(f),i===!0||isFinite(h)?h||0:f):f}}),u.each(["height","width"],function(e,t){u.cssHooks[t]={get:function(i,l,f){if(l)return un.test(u.css(i,"display"))&&(!i.getClientRects().length||!i.getBoundingClientRect().width)?Nr(i,dn,function(){return Or(i,t,f)}):Or(i,t,f)},set:function(i,l,f){var h,x=Ht(i),b=!E.scrollboxSize()&&x.position==="absolute",v=b||f,w=v&&u.css(i,"boxSizing",!1,x)==="border-box",R=f?Zt(i,t,f,w,x):0;return w&&b&&(R-=Math.ceil(i["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(x[t])-Zt(i,t,"border",!1,x)-.5)),R&&(h=Nt.exec(l))&&(h[3]||"px")!=="px"&&(i.style[t]=l,l=u.css(i,t)),Tr(i,l,R)}}}),u.cssHooks.marginLeft=Cr(E.reliableMarginLeft,function(e,t){if(t)return(parseFloat(St(e,"marginLeft"))||e.getBoundingClientRect().left-Nr(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),u.each({margin:"",padding:"",border:"Width"},function(e,t){u.cssHooks[e+t]={expand:function(i){for(var l=0,f={},h=typeof i=="string"?i.split(" "):[i];l<4;l++)f[e+Ze[l]+t]=h[l]||h[l-2]||h[0];return f}},e!=="margin"&&(u.cssHooks[e+t].set=Tr)}),u.fn.extend({css:function(e,t){return xe(this,function(i,l,f){var h,x,b={},v=0;if(Array.isArray(l)){for(h=Ht(i),x=l.length;v1)}});function Ae(e,t,i,l,f){return new Ae.prototype.init(e,t,i,l,f)}u.Tween=Ae,Ae.prototype={constructor:Ae,init:function(e,t,i,l,f,h){this.elem=e,this.prop=i,this.easing=f||u.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=l,this.unit=h||(u.cssNumber[i]?"":"px")},cur:function(){var e=Ae.propHooks[this.prop];return e&&e.get?e.get(this):Ae.propHooks._default.get(this)},run:function(e){var t,i=Ae.propHooks[this.prop];return this.options.duration?this.pos=t=u.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),i&&i.set?i.set(this):Ae.propHooks._default.set(this),this}},Ae.prototype.init.prototype=Ae.prototype,Ae.propHooks={_default:{get:function(e){var t;return e.elem.nodeType!==1||e.elem[e.prop]!=null&&e.elem.style[e.prop]==null?e.elem[e.prop]:(t=u.css(e.elem,e.prop,""),!t||t==="auto"?0:t)},set:function(e){u.fx.step[e.prop]?u.fx.step[e.prop](e):e.elem.nodeType===1&&(u.cssHooks[e.prop]||e.elem.style[$t(e.prop)]!=null)?u.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},Ae.propHooks.scrollTop=Ae.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},u.easing={linear:function(e){return e},swing:function(e){return .5-Math.cos(e*Math.PI)/2},_default:"swing"},u.fx=Ae.prototype.init,u.fx.step={};var vt,Yt,fn=/^(?:toggle|show|hide)$/,hn=/queueHooks$/;function er(){Yt&&(A.hidden===!1&&o.requestAnimationFrame?o.requestAnimationFrame(er):o.setTimeout(er,u.fx.interval),u.fx.tick())}function Lr(){return o.setTimeout(function(){vt=void 0}),vt=Date.now()}function Bt(e,t){var i,l=0,f={height:e};for(t=t?1:0;l<4;l+=2-t)i=Ze[l],f["margin"+i]=f["padding"+i]=e;return t&&(f.opacity=f.width=e),f}function Ir(e,t,i){for(var l,f=(qe.tweeners[t]||[]).concat(qe.tweeners["*"]),h=0,x=f.length;h1)},removeAttr:function(e){return this.each(function(){u.removeAttr(this,e)})}}),u.extend({attr:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2)){if(typeof e.getAttribute>"u")return u.prop(e,t,i);if((h!==1||!u.isXMLDoc(e))&&(f=u.attrHooks[t.toLowerCase()]||(u.expr.match.bool.test(t)?Ar:void 0)),i!==void 0){if(i===null){u.removeAttr(e,t);return}return f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:(e.setAttribute(t,i+""),i)}return f&&"get"in f&&(l=f.get(e,t))!==null?l:(l=u.find.attr(e,t),l??void 0)}},attrHooks:{type:{set:function(e,t){if(!E.radioValue&&t==="radio"&&ae(e,"input")){var i=e.value;return e.setAttribute("type",t),i&&(e.value=i),t}}}},removeAttr:function(e,t){var i,l=0,f=t&&t.match(Ne);if(f&&e.nodeType===1)for(;i=f[l++];)e.removeAttribute(i)}}),Ar={set:function(e,t,i){return t===!1?u.removeAttr(e,i):e.setAttribute(i,i),i}},u.each(u.expr.match.bool.source.match(/\w+/g),function(e,t){var i=_t[t]||u.find.attr;_t[t]=function(l,f,h){var x,b,v=f.toLowerCase();return h||(b=_t[v],_t[v]=x,x=i(l,f,h)!=null?v:null,_t[v]=b),x}});var xn=/^(?:input|select|textarea|button)$/i,gn=/^(?:a|area)$/i;u.fn.extend({prop:function(e,t){return xe(this,u.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[u.propFix[e]||e]})}}),u.extend({prop:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2))return(h!==1||!u.isXMLDoc(e))&&(t=u.propFix[t]||t,f=u.propHooks[t]),i!==void 0?f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:e[t]=i:f&&"get"in f&&(l=f.get(e,t))!==null?l:e[t]},propHooks:{tabIndex:{get:function(e){var t=u.find.attr(e,"tabindex");return t?parseInt(t,10):xn.test(e.nodeName)||gn.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),E.optSelected||(u.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),u.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){u.propFix[this.toLowerCase()]=this});function ct(e){var t=e.match(Ne)||[];return t.join(" ")}function dt(e){return e.getAttribute&&e.getAttribute("class")||""}function tr(e){return Array.isArray(e)?e:typeof e=="string"?e.match(Ne)||[]:[]}u.fn.extend({addClass:function(e){var t,i,l,f,h,x;return M(e)?this.each(function(b){u(this).addClass(e.call(this,b,dt(this)))}):(t=tr(e),t.length?this.each(function(){if(l=dt(this),i=this.nodeType===1&&" "+ct(l)+" ",i){for(h=0;h-1;)i=i.replace(" "+f+" "," ");x=ct(i),l!==x&&this.setAttribute("class",x)}}):this):this.attr("class","")},toggleClass:function(e,t){var i,l,f,h,x=typeof e,b=x==="string"||Array.isArray(e);return M(e)?this.each(function(v){u(this).toggleClass(e.call(this,v,dt(this),t),t)}):typeof t=="boolean"&&b?t?this.addClass(e):this.removeClass(e):(i=tr(e),this.each(function(){if(b)for(h=u(this),f=0;f-1)return!0;return!1}});var yn=/\r/g;u.fn.extend({val:function(e){var t,i,l,f=this[0];return arguments.length?(l=M(e),this.each(function(h){var x;this.nodeType===1&&(l?x=e.call(this,h,u(this).val()):x=e,x==null?x="":typeof x=="number"?x+="":Array.isArray(x)&&(x=u?.map(x,function(b){return b==null?"":b+""})),t=u.valHooks[this.type]||u.valHooks[this.nodeName.toLowerCase()],(!t||!("set"in t)||t.set(this,x,"value")===void 0)&&(this.value=x))})):f?(t=u.valHooks[f.type]||u.valHooks[f.nodeName.toLowerCase()],t&&"get"in t&&(i=t.get(f,"value"))!==void 0?i:(i=f.value,typeof i=="string"?i.replace(yn,""):i??"")):void 0}}),u.extend({valHooks:{option:{get:function(e){var t=u.find.attr(e,"value");return t??ct(u.text(e))}},select:{get:function(e){var t,i,l,f=e.options,h=e.selectedIndex,x=e.type==="select-one",b=x?null:[],v=x?h+1:f.length;for(h<0?l=v:l=x?h:0;l-1)&&(i=!0);return i||(e.selectedIndex=-1),h}}}}),u.each(["radio","checkbox"],function(){u.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=u.inArray(u(e).val(),t)>-1}},E.checkOn||(u.valHooks[this].get=function(e){return e.getAttribute("value")===null?"on":e.value})});var Rt=o.location,Pr={guid:Date.now()},rr=/\?/;u.parseXML=function(e){var t,i;if(!e||typeof e!="string")return null;try{t=new o.DOMParser().parseFromString(e,"text/xml")}catch{}return i=t&&t.getElementsByTagName("parsererror")[0],(!t||i)&&u.error("Invalid XML: "+(i?u?.map(i.childNodes,function(l){return l.textContent}).join(` +`):e)),t};var Fr=/^(?:focusinfocus|focusoutblur)$/,Wr=function(e){e.stopPropagation()};u.extend(u.event,{trigger:function(e,t,i,l){var f,h,x,b,v,w,R,I,C=[i||A],F=k.call(e,"type")?e.type:e,ne=k.call(e,"namespace")?e.namespace.split("."):[];if(h=I=x=i=i||A,!(i.nodeType===3||i.nodeType===8)&&!Fr.test(F+u.event.triggered)&&(F.indexOf(".")>-1&&(ne=F.split("."),F=ne.shift(),ne.sort()),v=F.indexOf(":")<0&&"on"+F,e=e[u.expando]?e:new u.Event(F,typeof e=="object"&&e),e.isTrigger=l?2:3,e.namespace=ne.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=i),t=t==null?[e]:u.makeArray(t,[e]),R=u.event.special[F]||{},!(!l&&R.trigger&&R.trigger.apply(i,t)===!1))){if(!l&&!R.noBubble&&!P(i)){for(b=R.delegateType||F,Fr.test(b+F)||(h=h.parentNode);h;h=h.parentNode)C.push(h),x=h;x===(i.ownerDocument||A)&&C.push(x.defaultView||x.parentWindow||o)}for(f=0;(h=C[f++])&&!e.isPropagationStopped();)I=h,e.type=f>1?b:R.bindType||F,w=(V.get(h,"events")||Object.create(null))[e.type]&&V.get(h,"handle"),w&&w.apply(h,t),w=v&&h[v],w&&w.apply&&Be(h)&&(e.result=w.apply(h,t),e.result===!1&&e.preventDefault());return e.type=F,!l&&!e.isDefaultPrevented()&&(!R._default||R._default.apply(C.pop(),t)===!1)&&Be(i)&&v&&M(i[F])&&!P(i)&&(x=i[v],x&&(i[v]=null),u.event.triggered=F,e.isPropagationStopped()&&I.addEventListener(F,Wr),i[F](),e.isPropagationStopped()&&I.removeEventListener(F,Wr),u.event.triggered=void 0,x&&(i[v]=x)),e.result}},simulate:function(e,t,i){var l=u.extend(new u.Event,i,{type:e,isSimulated:!0});u.event.trigger(l,null,t)}}),u.fn.extend({trigger:function(e,t){return this.each(function(){u.event.trigger(e,t,this)})},triggerHandler:function(e,t){var i=this[0];if(i)return u.event.trigger(e,t,i,!0)}});var vn=/\[\]$/,Hr=/\r?\n/g,bn=/^(?:submit|button|image|reset|file)$/i,jn=/^(?:input|select|textarea|keygen)/i;function nr(e,t,i,l){var f;if(Array.isArray(t))u.each(t,function(h,x){i||vn.test(e)?l(e,x):nr(e+"["+(typeof x=="object"&&x!=null?h:"")+"]",x,i,l)});else if(!i&&se(t)==="object")for(f in t)nr(e+"["+f+"]",t[f],i,l);else l(e,t)}u.param=function(e,t){var i,l=[],f=function(h,x){var b=M(x)?x():x;l[l.length]=encodeURIComponent(h)+"="+encodeURIComponent(b??"")};if(e==null)return"";if(Array.isArray(e)||e.jquery&&!u.isPlainObject(e))u.each(e,function(){f(this.name,this.value)});else for(i in e)nr(i,e[i],t,f);return l.join("&")},u.fn.extend({serialize:function(){return u.param(this.serializeArray())},serializeArray:function(){return this?.map(function(){var e=u.prop(this,"elements");return e?u.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!u(this).is(":disabled")&&jn.test(this.nodeName)&&!bn.test(e)&&(this.checked||!Ct.test(e))})?.map(function(e,t){var i=u(this).val();return i==null?null:Array.isArray(i)?u?.map(i,function(l){return{name:t.name,value:l.replace(Hr,`\r +`)}}):{name:t.name,value:i.replace(Hr,`\r +`)}}).get()}});var Dn=/%20/g,kn=/#.*$/,wn=/([?&])_=[^&]*/,En=/^(.*?):[ \t]*([^\r\n]*)$/mg,Nn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Cn=/^(?:GET|HEAD)$/,Sn=/^\/\//,Yr={},ar={},Br="*/".concat("*"),ir=A.createElement("a");ir.href=Rt.href;function qr(e){return function(t,i){typeof t!="string"&&(i=t,t="*");var l,f=0,h=t.toLowerCase().match(Ne)||[];if(M(i))for(;l=h[f++];)l[0]==="+"?(l=l.slice(1)||"*",(e[l]=e[l]||[]).unshift(i)):(e[l]=e[l]||[]).push(i)}}function Jr(e,t,i,l){var f={},h=e===ar;function x(b){var v;return f[b]=!0,u.each(e[b]||[],function(w,R){var I=R(t,i,l);if(typeof I=="string"&&!h&&!f[I])return t.dataTypes.unshift(I),x(I),!1;if(h)return!(v=I)}),v}return x(t.dataTypes[0])||!f["*"]&&x("*")}function sr(e,t){var i,l,f=u.ajaxSettings.flatOptions||{};for(i in t)t[i]!==void 0&&((f[i]?e:l||(l={}))[i]=t[i]);return l&&u.extend(!0,e,l),e}function _n(e,t,i){for(var l,f,h,x,b=e.contents,v=e.dataTypes;v[0]==="*";)v.shift(),l===void 0&&(l=e.mimeType||t.getResponseHeader("Content-Type"));if(l){for(f in b)if(b[f]&&b[f].test(l)){v.unshift(f);break}}if(v[0]in i)h=v[0];else{for(f in i){if(!v[0]||e.converters[f+" "+v[0]]){h=f;break}x||(x=f)}h=h||x}if(h)return h!==v[0]&&v.unshift(h),i[h]}function Rn(e,t,i,l){var f,h,x,b,v,w={},R=e.dataTypes.slice();if(R[1])for(x in e.converters)w[x.toLowerCase()]=e.converters[x];for(h=R.shift();h;)if(e.responseFields[h]&&(i[e.responseFields[h]]=t),!v&&l&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),v=h,h=R.shift(),h){if(h==="*")h=v;else if(v!=="*"&&v!==h){if(x=w[v+" "+h]||w["* "+h],!x){for(f in w)if(b=f.split(" "),b[1]===h&&(x=w[v+" "+b[0]]||w["* "+b[0]],x)){x===!0?x=w[f]:w[f]!==!0&&(h=b[0],R.unshift(b[1]));break}}if(x!==!0)if(x&&e.throws)t=x(t);else try{t=x(t)}catch(I){return{state:"parsererror",error:x?I:"No conversion from "+v+" to "+h}}}}return{state:"success",data:t}}u.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Rt.href,type:"GET",isLocal:Nn.test(Rt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Br,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":u.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?sr(sr(e,u.ajaxSettings),t):sr(u.ajaxSettings,e)},ajaxPrefilter:qr(Yr),ajaxTransport:qr(ar),ajax:function(e,t){typeof e=="object"&&(t=e,e=void 0),t=t||{};var i,l,f,h,x,b,v,w,R,I,C=u.ajaxSetup({},t),F=C.context||C,ne=C.context&&(F.nodeType||F.jquery)?u(F):u.event,de=u.Deferred(),oe=u.Callbacks("once memory"),Se=C.statusCode||{},Ce={},Ke={},Qe="canceled",ce={readyState:0,getResponseHeader:function(he){var we;if(v){if(!h)for(h={};we=En.exec(f);)h[we[1].toLowerCase()+" "]=(h[we[1].toLowerCase()+" "]||[]).concat(we[2]);we=h[he.toLowerCase()+" "]}return we==null?null:we.join(", ")},getAllResponseHeaders:function(){return v?f:null},setRequestHeader:function(he,we){return v==null&&(he=Ke[he.toLowerCase()]=Ke[he.toLowerCase()]||he,Ce[he]=we),this},overrideMimeType:function(he){return v==null&&(C.mimeType=he),this},statusCode:function(he){var we;if(he)if(v)ce.always(he[ce.status]);else for(we in he)Se[we]=[Se[we],he[we]];return this},abort:function(he){var we=he||Qe;return i&&i.abort(we),ft(0,we),this}};if(de.promise(ce),C.url=((e||C.url||Rt.href)+"").replace(Sn,Rt.protocol+"//"),C.type=t.method||t.type||C.method||C.type,C.dataTypes=(C.dataType||"*").toLowerCase().match(Ne)||[""],C.crossDomain==null){b=A.createElement("a");try{b.href=C.url,b.href=b.href,C.crossDomain=ir.protocol+"//"+ir.host!=b.protocol+"//"+b.host}catch{C.crossDomain=!0}}if(C.data&&C.processData&&typeof C.data!="string"&&(C.data=u.param(C.data,C.traditional)),Jr(Yr,C,t,ce),v)return ce;w=u.event&&C.global,w&&u.active++===0&&u.event.trigger("ajaxStart"),C.type=C.type.toUpperCase(),C.hasContent=!Cn.test(C.type),l=C.url.replace(kn,""),C.hasContent?C.data&&C.processData&&(C.contentType||"").indexOf("application/x-www-form-urlencoded")===0&&(C.data=C.data.replace(Dn,"+")):(I=C.url.slice(l.length),C.data&&(C.processData||typeof C.data=="string")&&(l+=(rr.test(l)?"&":"?")+C.data,delete C.data),C.cache===!1&&(l=l.replace(wn,"$1"),I=(rr.test(l)?"&":"?")+"_="+Pr.guid+++I),C.url=l+I),C.ifModified&&(u.lastModified[l]&&ce.setRequestHeader("If-Modified-Since",u.lastModified[l]),u.etag[l]&&ce.setRequestHeader("If-None-Match",u.etag[l])),(C.data&&C.hasContent&&C.contentType!==!1||t.contentType)&&ce.setRequestHeader("Content-Type",C.contentType),ce.setRequestHeader("Accept",C.dataTypes[0]&&C.accepts[C.dataTypes[0]]?C.accepts[C.dataTypes[0]]+(C.dataTypes[0]!=="*"?", "+Br+"; q=0.01":""):C.accepts["*"]);for(R in C.headers)ce.setRequestHeader(R,C.headers[R]);if(C.beforeSend&&(C.beforeSend.call(F,ce,C)===!1||v))return ce.abort();if(Qe="abort",oe.add(C.complete),ce.done(C.success),ce.fail(C.error),i=Jr(ar,C,t,ce),!i)ft(-1,"No Transport");else{if(ce.readyState=1,w&&ne.trigger("ajaxSend",[ce,C]),v)return ce;C.async&&C.timeout>0&&(x=o.setTimeout(function(){ce.abort("timeout")},C.timeout));try{v=!1,i.send(Ce,ft)}catch(he){if(v)throw he;ft(-1,he)}}function ft(he,we,Tt,lr){var Ge,Ot,Xe,it,st,He=we;v||(v=!0,x&&o.clearTimeout(x),i=void 0,f=lr||"",ce.readyState=he>0?4:0,Ge=he>=200&&he<300||he===304,Tt&&(it=_n(C,ce,Tt)),!Ge&&u.inArray("script",C.dataTypes)>-1&&u.inArray("json",C.dataTypes)<0&&(C.converters["text script"]=function(){}),it=Rn(C,it,ce,Ge),Ge?(C.ifModified&&(st=ce.getResponseHeader("Last-Modified"),st&&(u.lastModified[l]=st),st=ce.getResponseHeader("etag"),st&&(u.etag[l]=st)),he===204||C.type==="HEAD"?He="nocontent":he===304?He="notmodified":(He=it.state,Ot=it.data,Xe=it.error,Ge=!Xe)):(Xe=He,(he||!He)&&(He="error",he<0&&(he=0))),ce.status=he,ce.statusText=(we||He)+"",Ge?de.resolveWith(F,[Ot,He,ce]):de.rejectWith(F,[ce,He,Xe]),ce.statusCode(Se),Se=void 0,w&&ne.trigger(Ge?"ajaxSuccess":"ajaxError",[ce,C,Ge?Ot:Xe]),oe.fireWith(F,[ce,He]),w&&(ne.trigger("ajaxComplete",[ce,C]),--u.active||u.event.trigger("ajaxStop")))}return ce},getJSON:function(e,t,i){return u.get(e,t,i,"json")},getScript:function(e,t){return u.get(e,void 0,t,"script")}}),u.each(["get","post"],function(e,t){u[t]=function(i,l,f,h){return M(l)&&(h=h||f,f=l,l=void 0),u.ajax(u.extend({url:i,type:t,dataType:h,data:l,success:f},u.isPlainObject(i)&&i))}}),u.ajaxPrefilter(function(e){var t;for(t in e.headers)t.toLowerCase()==="content-type"&&(e.contentType=e.headers[t]||"")}),u._evalUrl=function(e,t,i){return u.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(l){u.globalEval(l,t,i)}})},u.fn.extend({wrapAll:function(e){var t;return this[0]&&(M(e)&&(e=e.call(this[0])),t=u(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t?.map(function(){for(var i=this;i.firstElementChild;)i=i.firstElementChild;return i}).append(this)),this},wrapInner:function(e){return M(e)?this.each(function(t){u(this).wrapInner(e.call(this,t))}):this.each(function(){var t=u(this),i=t.contents();i.length?i.wrapAll(e):t.append(e)})},wrap:function(e){var t=M(e);return this.each(function(i){u(this).wrapAll(t?e.call(this,i):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){u(this).replaceWith(this.childNodes)}),this}}),u.expr.pseudos.hidden=function(e){return!u.expr.pseudos.visible(e)},u.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},u.ajaxSettings.xhr=function(){try{return new o.XMLHttpRequest}catch{}};var Mn={0:200,1223:204},Mt=u.ajaxSettings.xhr();E.cors=!!Mt&&"withCredentials"in Mt,E.ajax=Mt=!!Mt,u.ajaxTransport(function(e){var t,i;if(E.cors||Mt&&!e.crossDomain)return{send:function(l,f){var h,x=e.xhr();if(x.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(h in e.xhrFields)x[h]=e.xhrFields[h];e.mimeType&&x.overrideMimeType&&x.overrideMimeType(e.mimeType),!e.crossDomain&&!l["X-Requested-With"]&&(l["X-Requested-With"]="XMLHttpRequest");for(h in l)x.setRequestHeader(h,l[h]);t=function(b){return function(){t&&(t=i=x.onload=x.onerror=x.onabort=x.ontimeout=x.onreadystatechange=null,b==="abort"?x.abort():b==="error"?typeof x.status!="number"?f(0,"error"):f(x.status,x.statusText):f(Mn[x.status]||x.status,x.statusText,(x.responseType||"text")!=="text"||typeof x.responseText!="string"?{binary:x.response}:{text:x.responseText},x.getAllResponseHeaders()))}},x.onload=t(),i=x.onerror=x.ontimeout=t("error"),x.onabort!==void 0?x.onabort=i:x.onreadystatechange=function(){x.readyState===4&&o.setTimeout(function(){t&&i()})},t=t("abort");try{x.send(e.hasContent&&e.data||null)}catch(b){if(t)throw b}},abort:function(){t&&t()}}}),u.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),u.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return u.globalEval(e),e}}}),u.ajaxPrefilter("script",function(e){e.cache===void 0&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),u.ajaxTransport("script",function(e){if(e.crossDomain||e.scriptAttrs){var t,i;return{send:function(l,f){t=u(" + + + +
+ + diff --git a/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md new file mode 100644 index 000000000..dfa4751f4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md @@ -0,0 +1,3802 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and is generated by [Changie](https://github.com/miniscruff/changie). + +## 7.9.3 - 2026-05-19 + +### Security +- Fixed media plugin `data-mce-object` injection leading to stored XSS. #TINY-14357 +- Fixed stored XSS vulnerability through `mce:protected` comments. #TINY-14353 +- Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. #TINY-14333 + +## 7.9.2 - 2026-02-11 + +### Deprecated +- The default value of `allow_html_in_comments` will change from `true` to `false` in TinyMCE 8.x. #TINY-11900 + +### Security +- Updated dependencies and parsing logic for enhanced content sanitization. HTML-like content in comments and certain legacy patterns are now sanitized more strictly when `xss_sanitization` is enabled (default). The Introduced `allow_html_in_comments` option provides control over comment node sanitization behavior. + #TINY-11900 +- Introduced `allow_html_in_comments` option (boolean, default: `true`) to control handling of HTML-like syntax in comment nodes. This option will default to `false` in TinyMCE 8.x. #TINY-11900 + +## 7.9.1 - 2025-05-29 + +### Improved +- Update `Notices` file and minified notices. #TINY-12091 + +## 7.9.0 - 2025-05-15 + +### Added +- Added new `disc` style option for unordered lists. #TINY-12015 + +### Improved +- The resize cursor now points in the correct direction for each resize mode. Patch contributed by daniloff200. ##GH-10189 +- If `style_formats` is empty, the button is now disabled. #TINY-12005 +- Inline dialog dropdowns reposition when the dialog is dragged or the window is scrolled. #TINY-11368 +- Bullet list icons were have been updated to better represent the default styles. #TINY-12014 + +### Changed +- The ContextFormSizeInput lock button is now centered instead of aligned to the end. #TINY-11916 +- Changed the default value of `advlist_bullet_styles` option to `default,disc,circle,square`. #TINY-12083 + +### Fixed +- Autolink no longer overrides already existing links when autolinking. #TINY-11836 +- Removed the deprecated CSS media selector `-ms-high-contrast`. #TINY-11876 +- The `mceInsertContent` command no longer deletes the parent block element when an anchor is selected. #TINY-11953 +- Table resizers are now visible when inline editor has a z-index property. #TINY-11981 +- Tabbing inside a `figcaption` element no longer displays two text insertion carets. #TINY-11997 +- Pressing Enter before a floating image no longer duplicates the image. #TINY-11676 +- Editor did not scroll into viewport on receiving focus on Chrome and Safari. #TINY-12017 +- Select UI elements was not properly styled on Chrome version 136. #TINY-12131 + +## 7.8.0 - 2025-04-09 + +### Added +- New subtoolbar support for context toolbars. #TINY-11748 +- New `extended_mathml_attributes` and `extended_mathml_elements` options. #TINY-11756 +- New `onboarding` option. #TINY-11931 + +### Improved +- Focus outline was misaligned with comment card border on saving an edit. #TINY-11329 +- The `editor.selection.scrollIntoView()` method now pads the target scroll area with a small margin, ensuring content doesn't sit at the very edge of the viewport. #TINY-11786 + +### Changed +- Changed promotional text and link. #TINY-11905 + +### Fixed +- Setting editor height to a `pt` or `em` value was ignoring min/max height settings. #TINY-11108 + +## 7.7.2 - 2025-03-19 + +### Fixed +- Error was thrown when pressing tab in the last cell of a non-editable table. #TINY-11797 +- Error was thrown when trying to use the context form API after a component was detached. #TINY-11781 +- Deleting an empty block within an
  • element would move cursor to the end of the
  • . #TINY-11763 +- Deleting an empty block that was between two lists would throw an Error when all three elements were nested inside a list. #TINY-11763 + +## 7.7.1 - 2025-03-05 + +### Fixed +- Skin UI content CSS was truncated when bundling, causing CSS styles to be missing. #TINY-11875 +- Context forms used to disappear if their input was disabled in the `onSetup` API. #TINY-11890 + +## 7.7.0 - 2025-02-20 + +### Added +- `link_attributes_postprocess` option that allows overriding attributes of a link that would be inserted through the link dialog. #TINY-11707 + +### Improved +- Improved visual indication of keyboard focus in annotations that contain an image. #TINY-11596 +- The type now defaults to `info` when `editor.notificationManager.open()` is used without a specified type or with an invalid one. #TINY-11661 + +### Changed +- Updated the `link` plugin behavior to move the cursor outside of the link when inserted or edited via the UI. Patch contributed by Philipp91. #GH-9998 + +### Fixed +- Keyboard navigation for size inputs in context forms. #TINY-11394 +- Keyboard navigation for context form sliders. #TINY-11482 +- The `insertContent` API was not replacing selected non-editable elements correctly. #TINY-11714 +- Context toolbar inputs had incorrect margins. #TINY-11624 +- Iframe aria text no longer suggests opening the help dialog when the help plugin is not enabled. #TINY-11672 +- Preview dialog no longer opens anchor links in a new tab. #TINY-11740 +- The `float` property was not properly removed on the image when converting a image into a captioned image. #TINY-11670 +- Expanding selection to word didn't work inside inline editing host elements. #TINY-11304 +- The `semantics` element in MathML was not properly retained when `annotation` elements were allowed. #TINY-11755 +- It was possible to tab to a toolbar group that had all children disabled. #TINY-11665 +- Keyboard navigation would get stuck on the 'more' toolbar button. #TINY-11762 +- Toolbar groups had both a `title` attribute and a custom tooltip, causing overlapping tooltips #TINY-11768 +- Toolbar text field did not render focus correctly. #TINY-11658 + +## 7.6.1 - 2025-01-22 + +### Fixed +- Text input was prevented in form elements in the contents of the editor. #TINY-11446 +- Opening a notification when the toolbar is positioned at the bottom of the editor threw an error. #TINY-11498 +- Table resize bars were not properly aligned for inline editors inside scrollable containers. #TINY-11215 + +## 7.6.0 - 2024-12-11 + +### Added +- It is now possible to create labeled groups in context toolbars. #TINY-11095 +- New `contextsliderform` and `contextsizeinput` context form types. #TINY-11342 +- New `back` function in `ContextFormApi` to go back to the previous toolbar. #TINY-11344 +- New `QuickbarInsertImage` command that is executed by the `quickimage` button. #TINY-11399 +- New `onSetup` function to the context form API. #TINY-11494 +- New `placeholder` to the context form input field API. #TINY-11459 +- New `disabled` option to restore the previous `readonly` mode behavior, allowing the editor to be displayed in a disabled state. #TINY-11488 + +### Improved +- Base64 data was not properly decoded due to unhandled URL-encoded characters. #TINY-9548 +- The `latin` list style type is now recognized as an alias for the `alpha` list style type. #TINY-11515 + +### Fixed +- Image selection was removed when calling `editor.nodeChanged()` while having focus inside the editor UI. #TINY-11437 +- Tooltip would not show for group toolbar button. #TINY-11391 +- Changing the table row type when a `contenteditable=false` cell was selected would not work as expected. #TINY-11383 +- The `samp` format was being applied as a `block` level format, instead of an `inline` format. #TINY-11390 +- Removed title attribute from dialog tree elements as they already have a tooltip. #TINY-11470 +- Fixed CSS bundling for skin UI content CSS. #TINY-11558 +- Fixed incorrect resource keys for CSS bundling JS files. #TINY-11558 + +## 7.5.0 - 2024-11-06 + +### Added +- Added support for using raw CSS in the list of possible colours, using the `color_map_raw` property. #GH-9788 + +### Improved +- Improved color picker aria support. #TINY-11291 + +### Fixed +- Autocompleter would not activate after applying an inline format like font size in some cases. #TINY-11273 +- The `toolbar-sticky-offset` would still be applied after entering fullscreen mode. #TINY-11137 +- Text and background color toolbar buttons would not be fully greyed out in readonly mode. #TINY-11313 +- Closing a nested modal dialog would lose focus from the editor. #TINY-11153 +- Inability to type '{' character on German keyboard layouts. #TINY-11395 + +## 7.4.1 - 2024-10-10 + +### Fixed +- Invalid HTML elements within SVG elements were not removed. #TINY-11332 + +## 7.4.0 - 2024-10-09 + +### Added +- New `context` property for all ui components. This allows buttons and menu items to be enabled or disabled based on whether their context matches a given predicate; status updates are checked on `init`, `NodeChange`, and `SwitchMode` events. #TINY-11211 +- Tree component now allows the addition of a custom icon. #TINY-11131 +- Added focus function to view button api. #TINY-11122 +- New option `allow_mathml_annotation_encodings` to opt-in to keep math annotations with specific encodings. #TINY-11166 +- Added global `color-active` LESS variable for use in editor skins. #TINY-11266 + +### Improved +- In read-only mode the editor now allows normal cursor movement and block element selection, including video playback. #TINY-11264 +- Pasting a table now places the cursor after the table instead of into the last cell. #TINY-11082 +- Dialog list dropdown menus now close when the browser window resizes. #TINY-11123 + +### Fixed +- Mouse hover on partially visible dialog collection elements no longer scrolls. #TINY-9915 +- Caret would unexpectedly shift to the non-editable table row above when pressing Enter. #TINY-11077 +- Deleting a selection in a list element would sometimes prevent the `input` event from being dispatched. #TINY-11100 +- Placing the cursor after a table with a br after it would misplace added newlines before the table instead of after. #TINY-11110 +- Sidebar could not be toggled until the skin was loaded. #TINY-11155 +- The image dialog lost focus after closing an image upload error alert. #TINY-11159 +- Copying tables to the clipboard did not correctly separate cells and rows for the "text/plain" MIME type. #TINY-10847 +- The editor resize handle was incorrectly rendered when all components were removed from the status bar. #TINY-11257 + +## 7.3.0 - 2024-08-07 + +### Added +- Colorpicker number input fields now show an error tooltip and error icon when invalid text has been entered. #TINY-10799 +- New `format-code` icon. #TINY-11018 + +### Improved +- When a full document was loaded as editor content the head elements were added to the body. #TINY-11053 + +### Fixed +- Unnecessary nbsp entities were inserted when typing at the edges of inline elements. #TINY-10854 +- Fixed JavaScript error when inserting a table using the context menu by adjusting the event order in `renderInsertTableMenuItem`. #TINY-6887 +- Notifications didn't position and resize properly when resizing the editor or toggling views. #TINY-10894 +- The pattern commands would execute even if the command was not enabled. #TINY-10994 +- Split button popups were incorrectly positioned when switching to fullscreen mode if the editor was inside a scrollable container. #TINY-10973 +- Sequential html comments would in some cases generate unwanted elements. #TINY-10955 +- The listbox component had a fixed width and was not a responsive ui element. #TINY-10884 +- Prevent default mousedown on toolbar buttons was causing misplaced focus bugs. #TINY-10638 +- Attempting to use focus commands on an editor where the cursor had last been in certain contentEditable="true" elements would fail. #TINY-11085 +- Colorpicker's hex-based input field showed the wrong validation error message. #TINY-11115 + +## 7.2.1 - 2024-07-03 + +### Fixed +- Text content could move unexpectedly when deleting a paragraph. #TINY-10590 +- Cursor would shift to the start of the editor body when focus was shifted to a noneditable cell of a table. #TINY-10127 +- Long translations of the bottom help text would cause minor graphical issues. #TINY-10961 +- Open Link button was disabled when selection partially covered a link or when multiple links were selected. #TINY-11009 + +## 7.2.0 - 2024-06-19 + +### Added +- Added `options.debug` API that logs the initial raw editor options to console. #TINY-10605 +- Added `referrerpolicy` as a valid attribute for an iframe element. #TINY-10374 +- New `onInit` and `stretched` properties to the `HtmlPanel` dialog component. #TINY-10900 +- Added support for querying the state of the `mceTogglePlainTextPaste` command. #TINY-10938 +- Added `for` option to dialog label components to improve accessibility. The value must be another component on the same dialog. #TINY-10971 + +### Improved +- Dialog slider components now emit an onChange event when using arrow keys. #TINY-10428 +- Accessibility for element path buttons, added tooltip to describe the button and removed incorrect `aria-level` attribute. #TINY-10891 +- Improve merging of inserted inline elements by removing nodes with redundant inheritable styles. #TINY-10869 +- Improved Find & Replace dialog accessibility by changing placeholders to labels. #TINY-10871 + +### Changed +- Replaced tiny branding logo with `Build with TinyMCE` text and logo. #TINY-11001 + +### Fixed +- Deleting in a `div` with preceeding `br` elements would sometimes throw errors. #TINY-10840 +- `autoresize_bottom_margin` was not reliably applied in some situations. #TINY-10793 +- Fixed cases where adding a newline around a br, table or img would not move the cursor to a new line. #TINY-10384 +- Focusing on `contenteditable="true"` element when using `editable_root: false` and inline mode causing selection to be shifted. #TINY-10820 +- Corrected the `role` attribute on listbox dialog components to `combobox` when there are no nested menu items. #TINY-10807 +- HTML entities that were double decoded in `noscript` elements caused an XSS vulnerability. #TINY-11019 +- It was possible to inject XSS HTML that was not matching the regexp when using the `noneditable_regexp` option. #TINY-11022 + +## 7.1.2 - 2024-06-05 + +### Fixed +- CSS color values set to `transparent` were incorrectly converted to '#000000`. #TINY-10916 + +## 7.1.1 - 2024-05-22 + +### Fixed +- Insert/Edit image dialog lost focus after the image upload completed. #TINY-10885 +- Deleting into a list from a paragraph that has an `img` tag could cause extra inline styles to be added. #TINY-10892 +- Resolved an issue where emojis configured with the `emojiimages` database were not loading correctly due to a broken CDN. #TINY-10878 +- Iframes in dialogs were not rendering rounded borders correctly. #TINY-10901 +- Autocompleter possible values are no longer capped at a length of 10. #TINY-10942 + +## 7.1.0 - 2024-05-08 + +### Added +- Parser support for math elements. #TINY-10809 +- New `math-equation` icon. #TINY-10804 + +### Improved +- Included `itemprop`, `itemscope` and `itemtype` as valid HTML5 attributes in the core schema. #TINY-9932 +- Notification accessibility improvements: added tooltips, keyboard navigation and shortcut to focus on notifications. #TINY-6925 +- Removed `aria-pressed` from the `More` button in sliding toolbar mode and replaced it with `aria-expanded`. #TINY-10795 +- The editor UI now renders correctly in Windows High Contrast Mode. #TINY-10781 + +### Fixed +- Backspacing in certain html setups resulted in data moving around unexpectedly. #TINY-10590 +- Dialog title markup changed to use an `h1` element instead of `div`. #TINY-10800 +- Dialog title was not announced in macOS VoiceOver, dialogs now use `aria-label` instead of `aria-labelledby` on macOS. #TINY-10808 +- Theme loader did not respect the suffix when it was loading skin CSS files. #TINY-10602 +- Custom block elements with colon characters would throw errors. #TINY-10813 +- Tab navigation in views didn't work. #TINY-10780 +- Video and audio elements could not be played on Safari. #TINY-10774 +- `ToggleToolbarDrawer` command did not toggle the toolbar in `sliding` mode when `{skipFocus: true}` parameter was passed. #TINY-10726 +- The buttons in the custom view header were clipped on when overflowing. #TINY-10741 +- In the custom view, the scrollbar of the container was not visible if its height was greater than the editor. #TINY-10741 +- Fixed accessibility issue by removing duplicate `role="menu"` attribute from color swatches. #TINY-10806 +- Fullscreen mode now prevents focus from leaving the editor. #TINY-10597 +- Open link context menu action did not work with selection surrounding a link. #TINY-10391 +- Styles were not retained when toggling a list on and off. #TINY-10837 +- Caret and placeholder text were invisible in Windows High Contrast Mode. #TINY-9811 +- Firefox did not announce the iframe title when `iframe_aria_text` was set. #TINY-10718 +- Notification width was not constrained to the width of the editor. #TINY-10886 +- Open link context menu action was not enabled for links on images. #TINY-10391 + +## 7.0.1 - 2024-04-10 + +### Fixed +- Toggle list behavior generated wrong html when the `forced_root_block` option was set to `div`. #TINY-10488 +- Tapping inside a composed text on Firefox Android would not close the autocompleter. #TINY-10715 +- An inline editor toolbar now behaves correctly in horizontally scrolled containers. #TINY-10684 +- Tooltips unintended shrinking and incorrectly positioned when shown in horizontally scrollable container. #TINY-10797 +- The status bar was invisible when the editor's height is short. #TINY-10705 + +## 7.0.0 - 2024-03-20 + +### Added +- New `license_key` option that must be set to `gpl` or a valid license key. #TINY-10681 +- New custom tooltip functionality, tooltip will be shown when hovering with a mouse or with keyboard focus. #TINY-9275 +- New `sandbox_iframes_exclusions` option that holds a list of URL host names to be excluded from iframe sandboxing when `sandbox_iframes` is set to `true`. #TINY-10350 +- Added 'getAllEmojis' api function to the emoticons plugin. #TINY-10572 +- Element preset support for the `valid_children` option and Schema.addValidChildren API. #TINY-9979 +- A new `trigger` property for block text pattern configurations, allowing pattern activation with either Space or Enter keys. #TINY-10324 +- onFocus callback for CustomEditor dialog component. #TINY-10596 +- icons for the import from Word, export to Word and export to PDF premium plugins. #TINY-10612 +- `data` is now a valid element in the Schema. #TINY-10611 +- More advanced schema config for custom elements. #TINY-9980 +- Custom tooltip for autocompleter, now visible on both mouse hover and keyboard focus, except single column cases. #TINY-9638 + +### Improved +- Included keyboard shortcut in custom tooltip for `ToolbarButton` and `ToolbarToggleButton`. #TINY-10487 +- Improved showing which element has focus for keyboard navigation. #TINY-9176 +- Custom tooltips will now show for items in `collection` which is rendered inside a dialog, on mouse hover and keyboard focus. #TINY-9637 +- Autocompleter will now work with IMEs. #TINY-10637 +- Make table ghost element better reflect height changes when resizing. #TINY-10658 + +### Changed +- TinyMCE is now licensed GPL Version 2 or later. #TINY-10578 +- `convert_unsafe_embeds` editor option is now defaulted to `true`. #TINY-10351 +- `sandbox_iframes` editor option is now defaulted to `true`. #TINY-10350 +- The DOMUtils.isEmpty API function has been modified to consider nodes containing only comments as empty. #TINY-10459 +- The `highlight_on_focus` option now defaults to true, adding a focus outline to every editor. #TINY-10574 +- Delay before the tooltip to show up, from 800ms to 300ms. #TINY-10475 +- Now `tox-view__pane` has `position: relative` instead of `static`. #TINY-10561 +- Update outbound link for statusbar Tiny logo #TINY-10494 +- Remove the height field from the `table` plugin cell dialog. The `table` plugin row dialog now controls the row height by setting the height on the `tr` element, not the `td` elements. #TINY-10617 +- Change table height resizing handling to remove heights from `td`/`th` elements and only apply to `tr` elements. #TINY-10589 +- Removed incorrect `aria-placeholder` attribute from editor body when `placeholder` option is set. #TINY-10452 +- The `tooltip` property for dialog's footer `togglebutton` is now optional. #TINY-10672 +- Changed the `media_url_resolver` option to use promises. #TINY-9154 +- `Styles` bespoke toolbar button fallback changed to `Formats` if `Paragraph` is not configured in `style_formats` option. #TINY-10603 +- Updated deprecation/removed console message. #TINY-10694 + +### Removed +- Deprecated `force_hex_color` option, with the default now being all colors are forced to hex format as lower case. #TINY-10436 +- Deprecated `remove_trailing_brs` option from DomParser. #TINY-10454 +- `title` attribute on buttons with visible label. #TINY-10453 +- `InsertOrderedList` and `InsertUnorderedList` commands from core, these now only exist in the `lists` plugin. #TINY-10644 +- `closeButton` from the notification API, close buttons in notifications are now required. #TINY-10646 +- The autocompleter `ch` configuration property has been removed. Use the `trigger` property instead. #TINY-8929 +- Deprecated `template` plugin. #TINY-10654 + +### Fixed +- When deleting the last row in a table, the cursor would jump to the first cell (top left), instead of moving to the next adjacent cell in some cases. #TINY-6309 +- Heading formatting would be partially applied to the content within the `summary` element when the caret was positioned between words. #TINY-10312 +- Moving focus to the outside of the editor after having clicked a menu would not fire a `blur` event as expected. #TINY-10310 +- Autocomplete would sometimes cause corrupt data when starting during text composition. #TINY-10317 +- Inline mode with persisted toolbar would show regardless of the skin being loaded, causing css issues. #TINY-10482 +- Table classes couldn't be removed via setting an empty value in `table_class_list`. Also fixed being forced to pick the first class option. #TINY-6653 +- Directly right clicking on a ol's li in FireFox didn't enable the button `List Properties...` in the context menu. #TINY-10490 +- The `link_default_target` option wasn't considered when inserting a link via `quicklink` toolbar. #TINY-10439 +- When inline editor toolbar wrapped to multiple lines the top wasn't always calculated correctly. #TINY-10580 +- Removed manually dispatching dragend event on drop in Firefox. #TINY-10389 +- Slovenian help dialog content had a dot in the wrong place. #TINY-10601 +- Pressing Backspace at the start of an empty `summary` element within a `details` element nested in a list item no longer removes the `summary` element. #TINY-10303 +- The toolbar width was miscalculated for the inline editor positioned inside a scrollable container. #TINY-10581 +- Fixed incorrect object processor for `event_root` option. #TINY-10433 +- Adding newline after using `selection.setContent` to insert a block element would throw an unhandled exception. #TINY-10560 +- Floating toolbar buttons in inline editor incorrectly wrapped into multiple rows on window resizing or zooming. #TINY-10570 +- When setting table border width and `table_style_by_css` is true, only the border attribute is set to 0 and border-width styling is no longer used. #TINY-10308 +- Clicking to the left or right of a non-editable div in Firefox would show two cursors. #TINY-10314 + +## 6.8.3 - 2024-02-08 + +### Changed +- Update outbound TinyMCE website links. #TINY-10491 + +### Fixed +- The floating toolbar would not be fully visible when the editor was placed inside a scrollable container. #TINY-10335 +- ShadowDOM skin was not loaded properly when used with js bundling feature. #TINY-10451 + +## 6.8.2 - 2023-12-11 + +### Fixed +- Bespoke select toolbar buttons including `fontfamily`, `fontsize`, `blocks`, and `styles` incorrectly used plural words in their accessible names. #TINY-10426 +- The `align` bespoke select toolbar button had an accessible name that was misleading and grammatically incorrect in certain cases. #TINY-10435 +- Accessible names of bespoke select toolbar buttons including `align`, `fontfamily`, `fontsize`, `blocks`, and `styles` were incorrectly translated. #TINY-10426 #TINY-10435 +- Clicking inside table cells with heavily nested content could cause the browser to hang. #TINY-10380 +- Toggling a list that contains an LI element having another list as its first child would remove the remaining content within that LI element. #TINY-10414 + +## 6.8.1 - 2023-11-29 + +### Improved +- Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287 + +### Fixed +- Translation syntax for announcement text in the table grid was incorrectly formatted. #TINY-10141 +- The functions `schema.isWrapper` and `schema.isInline` did not exclude node names that started with `#` which should not be considered as elements. #TINY-10385 + +## 6.8.0 - 2023-11-22 + +### Added +- CSS files are now also generated as separate JS files to improve bundling of all resources. #TINY-10352 +- Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352 +- Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352 +- Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819 +- Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290 +- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348 +- New `convert_unsafe_embeds` option that controls whether `` and `` elements will be converted to more restrictive alternatives, namely `` for image MIME types, `