Files
edr-platform/apps/edr-freight-web/portal/src/App.tsx
Nathnael ab5a4117df fix: ui
2026-08-14 07:04:57 +00:00

573 lines
21 KiB
TypeScript

import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
Home,
Layers,
LifeBuoy,
Loader2,
// MapPin,
Package,
Receipt,
Settings,
} from "lucide-react";
import { useEffect, useRef } from "react";
import {
Navigate,
Outlet,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
import OnboardingResumeBanner, {
AccountReviewBanner,
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import useAuth from "./hooks/useAuth";
import { useIdentify } from "./lib/posthog";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./utils/refreshScheduler";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
import ResetPasswordLinkPage from "./pages/accounts/ResetPasswordLinkPage";
import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractsList from "./pages/contracts/ContractsList";
import NewContractPage from "./pages/contracts/NewContractPage";
import NewShipmentPage from "./pages/contracts/NewShipmentPage";
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import {
ShippingLineBookingDetailPage,
ShippingLineBookingsPage,
ShippingLineCompletePage,
ShippingLineHelpPage,
ShippingLineHomePage,
ShippingLineInvoicesPage,
ShippingLineSettingsPage,
} from "./pages/shipping-line";
import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
import TermsPage from "./pages/support/TermsPage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="animate-spin text-primary" />
</div>
);
}
function LogoutHandler() {
const { logout } = useAuth();
const navigate = useNavigate();
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
logout().then(() => navigate("/login", { replace: true }));
}, []);
return <FullScreenSpinner />;
}
/** Blocks unauthenticated users; renders children only with a valid session. */
function RequireAuth() {
const { isPending, isAuthenticated } = useAuth();
const location = useLocation();
if (isPending) return <FullScreenSpinner />;
if (!isAuthenticated)
return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
/**
* Waits for the company query so downstream routes can rely on it being
* resolved. Onboarding is enforced by OnboardingGate, not here.
*/
function RequireCompany() {
const { customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
return <Outlet />;
}
/**
* Routes an un-onboarded user may still visit. The wizard auto-opens but is
* dismissable, so they can browse these freely; any other route forces the
* wizard back open and bounces them home.
*/
const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"];
function isOnboardingAllowedPath(pathname: string): boolean {
const path = pathname.toLowerCase();
return ONBOARDING_ALLOWED_PATHS.some(
(p) => path === p || path.startsWith(p + "/"),
);
}
/**
* Enforces first-run onboarding. The home (dashboard) and signature pages stay
* reachable while onboarding is incomplete; the wizard auto-opens on login but
* can be dismissed to use those pages. Visiting any other page bounces back to
* home and re-opens the wizard. New users (no company yet) are treated the same
* as users who haven't completed onboarding.
*
* Shipping lines are exempt: staff register them with their details already
* captured, so there is nothing for them to onboard — they go straight to home.
*/
function OnboardingGate() {
const { company, onboardingCompleted, isShippingLine } = useAuth();
const location = useLocation();
// Keyed off a positive shipping-line identification, never off "no company":
// that is also true mid-fetch and on error, which would let customers slip
// past onboarding whenever the request failed.
const needsOnboarding = isShippingLine
? false
: !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
useDisclosure(false);
// Re-evaluate on every navigation: force the wizard open on blocked routes,
// and auto-open on first arrival while onboarding is pending.
useEffect(() => {
if (needsOnboarding && !allowedHere) {
openWizard();
}
}, [needsOnboarding, allowedHere, location.pathname, openWizard]);
// Auto-open once when onboarding becomes/loads as pending (login).
const autoOpenedRef = useRef(false);
useEffect(() => {
if (needsOnboarding && !autoOpenedRef.current) {
autoOpenedRef.current = true;
openWizard();
}
if (!needsOnboarding) autoOpenedRef.current = false;
}, [needsOnboarding, openWizard]);
if (needsOnboarding && !allowedHere) {
return <Navigate to="/portal" replace />;
}
return (
<>
{needsOnboarding && <OnboardingResumeBanner onResume={openWizard} />}
{!needsOnboarding && <AccountReviewBanner />}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
onClose={closeWizard}
/>
</>
);
}
/**
* Customer-only routes. A shipping line that lands on one (an old link, a
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
* contract/company-shaped page that has no meaning for it.
*/
function RequireCustomer() {
const { isShippingLine, customerQuery } = useAuth();
// RequireCompany already awaits this query, but guard anyway: a refetch can
// flip `isPending` back on, and redirecting on a half-loaded account would
// throw the user into the wrong app.
if (customerQuery.isPending) return <FullScreenSpinner />;
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
return <Outlet />;
}
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
function RequireShippingLine() {
const { isShippingLine, customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (!isShippingLine) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/**
* Where a signed-in account belongs. Shipping lines and customers have separate
* apps, so every "you're already logged in" redirect has to pick between them.
* Waits for the company query: `isShippingLine` is false while that request is
* still in flight, which would land a shipping line on the customer home first.
*/
function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, customerQuery } = useAuth();
return {
ready: !customerQuery.isPending,
href: isShippingLine ? "/shipping-line" : "/portal",
};
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
const home = useHomeRoute();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) {
if (!home.ready) return <FullScreenSpinner />;
return <Navigate to={home.href} replace />;
}
return <Outlet />;
}
/** Landing page for visitors; authenticated users go straight to the portal. */
function LandingRoute() {
const { isPending, isAuthenticated } = useAuth();
const home = useHomeRoute();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) {
if (!home.ready) return <FullScreenSpinner />;
return <Navigate to={home.href} replace />;
}
return <EDRFreightLandingPage />;
}
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
{
label: "Contracts",
href: "/contracts",
icon: <Layers size={18} />,
},
{
label: "Bookings",
href: "/bookings",
icon: <Package size={18} />,
},
// {
// label: "Tracking",
// href: "/tracking",
// icon: <MapPin size={18} />,
// },
{
label: "Invoices",
href: "/billing",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Settings",
href: "/settings",
icon: <Settings size={18} />,
},
{
section: "Account",
label: "Help & Support",
href: "/help",
icon: <LifeBuoy size={18} />,
},
];
/**
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
* Bookings / Invoices pages are different pages at different routes.
*/
const shippingLineSidebarItems: SidebarItem[] = [
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
{
label: "Bookings",
href: "/shipping-line/bookings",
icon: <Package size={18} />,
},
{
label: "Invoices",
href: "/shipping-line/invoices",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Settings",
href: "/shipping-line/settings",
icon: <Settings size={18} />,
},
{
section: "Account",
label: "Help & Support",
href: "/shipping-line/help",
icon: <LifeBuoy size={18} />,
},
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const {
user,
company,
companyType,
createProfile,
reapplyProfile,
isAuthenticated,
isShippingLine,
} = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user, company);
// Keep the server session alive while a user is logged in. Runs after
// login, signup, and page-reload bootstrap alike.
useEffect(() => {
if (!isAuthenticated) {
stopTokenRefreshScheduler();
return;
}
startTokenRefreshScheduler();
return stopTokenRefreshScheduler;
}, [isAuthenticated]);
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
return (
<>
{/* Global API error modal — shows the server's actual error message for
every failed request (suppressed on onboarding/auth pages). */}
<ApiErrorModal />
<Routes>
{/* Public routes */}
<Route index element={<LandingRoute />} />
<Route path="/logout" element={<LogoutHandler />} />
<Route
path="/booking/check-status/:orderId"
element={<CheckPaymentPage />}
/>
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
{/* Fayda (eSignet) redirect_uri — the whole tab lands here after
verification, completes the code/state exchange and navigates back
to the page that started it. Public on purpose: behind RequireAuth
the onboarding gate would redirect away before the exchange ran. */}
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />
{/* Help and legal pages. Public on purpose: the auth screens link to
them before a session exists, so they carry their own chrome rather
than sitting inside the authenticated app layout. */}
<Route path="/help" element={<HelpPage />} />
<Route path="/faq" element={<FaqPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/terms" element={<TermsPage />} />
{/* Auth pages — inaccessible once logged in */}
<Route element={<RedirectIfAuthed />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
{/* Staff-issued reset links land here. Deliberately outside
RedirectIfAuthed: a customer with a stale session still needs the link
to work, and the token — not the session — is what authorises it. */}
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
{/* Signup-flow pages; reached while a session already exists */}
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route element={<RequireAuth />}>
<Route element={<RequireCompany />}>
{/* Shipping-line app. Its own layout and sidebar, and its own pages
at their own routes — nothing here is shared with the customer
branch below beyond the shell component itself. Contracts are
absent by design: shipping lines request bookings directly. */}
{isShippingLine && (
<Route element={<RequireShippingLine />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={shippingLineSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
// Support chat is company-scoped; a shipping line has no
// company, so every poll would 403.
showSupportWidget={false}
>
<Outlet />
</AppLayout>
}
>
<Route
path="/shipping-line"
element={<ShippingLineHomePage />}
/>
<Route
path="/shipping-line/bookings"
element={<ShippingLineBookingsPage />}
/>
<Route
path="/shipping-line/bookings/:id"
element={<ShippingLineBookingDetailPage />}
/>
<Route
path="/shipping-line/bookings/:id/complete"
element={<ShippingLineCompletePage />}
/>
<Route
path="/shipping-line/invoices"
element={<ShippingLineInvoicesPage />}
/>
{/* Same detail component as the customer's /billing/:id — the
API scopes my-invoices to the signed-in payer either way,
and the page derives its back target from the URL. */}
<Route
path="/shipping-line/invoices/:id"
element={<InvoiceDetailPage />}
/>
<Route
path="/shipping-line/settings"
element={<ShippingLineSettingsPage />}
/>
<Route
path="/shipping-line/settings"
element={<ShippingLineSettingsPage />}
/>
<Route
path="/shipping-line/help"
element={<ShippingLineHelpPage />}
/>
{/* Old shared links land on the shipping-line equivalents. */}
<Route
path="/settings"
element={<Navigate to="/shipping-line/settings" replace />}
/>
</Route>
</Route>
)}
{/* Customer app — unchanged. */}
<Route element={<RequireCustomer />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
onCreateProfile={createProfile}
onReapplyProfile={reapplyProfile}
>
<OnboardingGate />
</AppLayout>
}
>
<Route path="/portal" element={<MyPortalPage />} />
{/* Bookings are created against a contract, but the full list is
browsable here. New-booking entry still routes via a contract. */}
<Route path="/bookings" element={<BookingsListPage />} />
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}
/>
<Route
path="/bookings/:id/edit"
element={<EditBookingPage />}
/>
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/last-mile-contract"
element={<LastMileContractPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route
path="/contracts/:id/edit"
element={<NewContractPage mode="edit" />}
/>
<Route
path="/contracts/:id/shipment-requests/new"
element={<NewShipmentRequestPage />}
/>
<Route
path="/contracts/:id/bookings/new"
element={<NewShipmentPage />}
/>
{/* Completion of an initiated (bare) booking after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="/contracts/:id/bookings/:bookingId/complete"
element={<NewShipmentPage />}
/>
<Route
path="/contracts/:id/view"
element={<ContractViewPage />}
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"
element={<Navigate to="/settings" replace />}
/>
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
};
export default App;