mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
346 lines
12 KiB
TypeScript
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;
|