Files
edr-platform/apps/edr-freight-web/portal/src/App.tsx
2026-07-13 12:11:12 +03:00

346 lines
12 KiB
TypeScript

import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
Home,
Layers,
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 useAuth from "./hooks/useAuth";
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 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 ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
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 PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
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.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const location = useLocation();
const needsOnboarding = !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}
/>
</>
);
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/** Landing page for visitors; authenticated users go straight to the portal. */
function LandingRoute() {
const { isPending, isAuthenticated } = useAuth();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" 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} />,
},
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, company, companyType, createProfile, isAuthenticated } =
useAuth();
// 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 (
<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) */}
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />
{/* 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>
{/* 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 />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
onCreateProfile={createProfile}
>
<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/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/clearance"
element={<ContractClearanceFlow />}
/>
<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 path="*" element={<Navigate to="/" replace />} />
</Routes>
);
};
export default App;