feat: add shipping line companies management

- Implement ShippingLineCompaniesService for registering and managing shipping line companies.
- Create ResendActivationAction component for resending activation links to shipping lines.
- Develop ShippingLineCompaniesPage for listing and registering shipping lines with validation.
- Introduce shippingLineCompanies.service for API interactions related to shipping lines.
- Define types for shipping line companies, including registration and pagination.
- Add placeholder pages for shipping line portal, including home, bookings, help, invoices, and settings.
This commit is contained in:
marshalyordanos
2026-08-13 08:54:20 +03:00
parent e37f1e0807
commit 9aae132dd4
42 changed files with 2463 additions and 117 deletions

View File

@@ -59,6 +59,13 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import {
ShippingLineBookingsPage,
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";
@@ -129,12 +136,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* 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 } = useAuth();
const { company, onboardingCompleted, isShippingLine } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
// 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).
@@ -176,21 +191,69 @@ function OnboardingGate() {
);
}
/**
* 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) return <Navigate to="/portal" replace />;
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) return <Navigate to="/portal" replace />;
if (isAuthenticated) {
if (!home.ready) return <FullScreenSpinner />;
return <Navigate to={home.href} replace />;
}
return <EDRFreightLandingPage />;
}
@@ -230,6 +293,37 @@ const sidebarItems: SidebarItem[] = [
},
];
/**
* 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();
@@ -310,81 +404,134 @@ const App = () => {
<Route element={<RequireAuth />}>
<Route element={<RequireCompany />}>
<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
{/* 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. */}
<Route element={<RequireShippingLine />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={shippingLineSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
>
<Outlet />
</AppLayout>
}
>
<Route
path="/shipping-line"
element={<ShippingLineHomePage />}
/>
<Route
path="/shipping-line/bookings"
element={<ShippingLineBookingsPage />}
/>
<Route
path="/shipping-line/invoices"
element={<ShippingLineInvoicesPage />}
/>
<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
<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
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>

View File

@@ -107,23 +107,20 @@ function getActivePage(
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;
// Longest match wins, for the same reason as the sidebar's isItemActive:
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
// parent, which a first-match-wins scan would report as "Home".
const best = items
.flatMap((item) => [item, ...(item.children ?? [])])
.filter(
(item) =>
path === item.href.toLowerCase() ||
path.startsWith(item.href.toLowerCase() + "/"),
)
.sort((a, b) => b.href.length - a.href.length)[0];
return best ? { label: best.label } : null;
}
const navClassNames = (active: boolean) => {
@@ -262,9 +259,19 @@ export function AppLayout({
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isSuspendedAppeal = reapplyStatus === "suspended";
// Longest matching href wins. A plain prefix test would light up every
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
// bookings", Home would stay highlighted on every page beneath it. Exact
// matches still win outright, so customer routes are unaffected — their
// sidebar hrefs are siblings, never nested inside one another.
const bestMatchHref = sidebarItems
.flatMap((item) => [item, ...(item.children ?? [])])
.map((item) => item.href.toLowerCase())
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
.sort((a, b) => b.length - a.length)[0];
const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() ||
activePath.startsWith(item.href.toLowerCase() + "/");
bestMatchHref === item.href.toLowerCase();
return (
<AppShell
@@ -371,7 +378,9 @@ export function AppLayout({
onClick={() =>
openServiceModal(p.type as ServiceType, p)
}
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
leftSection={
<RefreshCw size={15} strokeWidth={1.8} />
}
>
{serviceLabel(p.type as ServiceType)}
</Menu.Item>
@@ -893,7 +902,9 @@ export function AppLayout({
</Alert>
)}
<FileInput
label={reapplyId ? "Business license (optional)" : "Business license"}
label={
reapplyId ? "Business license (optional)" : "Business license"
}
multiple
clearable
accept="application/pdf,image/png,image/jpeg"

View File

@@ -162,7 +162,15 @@ export default function OnboardingResumeBanner({
* Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
const { company, companyStatus, reviewStatus, reviewNote, isShippingLine } =
useAuth();
// Shipping lines have no company approval, no operational profiles and no
// profile-edit review — every branch below is about customer state they do
// not have. Bail explicitly rather than relying on each check happening to
// fall through.
if (isShippingLine) return null;
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");

View File

@@ -1,6 +1,12 @@
import { api } from "@/services/api";
import type { ProfileTypeValue } from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type {
CompanyInfoResponse,
ProfileTypeValue,
} from "@/services/companies.service";
import {
companiesService,
isShippingLineAccount,
} from "@/services/companies.service";
import type {
LoginPayload,
LoginResponse,
@@ -158,7 +164,21 @@ const useAuth = () => {
}
};
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
/**
* Shipping lines share the portal with customers but have no company, no
* external profile and no onboarding. Identified positively from the
* backend's discriminator — never from "company is missing", which is also
* true while the fetch is in flight or after it fails.
*/
const isShippingLine = isShippingLineAccount(accountInfo);
const shippingLine = isShippingLine ? accountInfo : null;
// Every customer-shaped field below is null/empty for a shipping line.
const companyInfo = isShippingLine
? null
: (accountInfo as CompanyInfoResponse | null);
const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active).
@@ -259,8 +279,11 @@ const useAuth = () => {
isPending,
isAuthenticated,
user: isAuthenticated ? (authQuery.data ?? null) : null,
company: isAuthenticated ? (companyQuery.data ?? null) : null,
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
// The whole getInfo payload, not the `company` field within it (historical
// name). Null for a shipping line: consumers read `.company` / `.profile`
// off it, and a shipping line has neither.
company: companyInfo,
customer: companyInfo,
canBook,
hasActiveProfile,
hasPendingProfile,
@@ -272,6 +295,8 @@ const useAuth = () => {
isUnderReview,
onboardingCompleted,
onboardingStep,
isShippingLine,
shippingLine,
createProfile,
reapplyProfile,
login,

View File

@@ -1,6 +1,6 @@
import { type FormEvent, useState } from "react";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { AlertCircle } from "lucide-react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
@@ -19,6 +19,10 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const passwordWasReset =
(location.state as { passwordReset?: boolean } | null)?.passwordReset ===
true;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
@@ -56,6 +60,21 @@ export default function LoginPage() {
</div>
<Stack gap="md">
{/*
Set by the reset flows on their way here. Without it the account
holder lands on a bare sign-in form with no sign the reset worked —
and the link is single-use, so there is no way back to check.
*/}
{passwordWasReset ? (
<Alert
color="green"
variant="light"
icon={<CheckCircle2 size={18} />}
>
Your password has been updated. Sign in with your new password.
</Alert>
) : null}
<TextInput
label="Email or Phone"
placeholder="name@company.com or 09XXXXXXXX"

View File

@@ -1,6 +1,6 @@
import { type FormEvent, useEffect, useState } from "react";
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
import { AlertCircle, KeyRound } from "lucide-react";
import { AlertCircle, CheckCircle2, KeyRound, LogIn } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import AuthShell from "@/components/auth/AuthShell";
@@ -33,6 +33,7 @@ export default function ResetPasswordLinkPage() {
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [succeeded, setSucceeded] = useState(false);
useEffect(() => {
if (!userId || !token) {
@@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() {
newPassword: password,
confirmPassword,
});
navigate("/login", { replace: true, state: { passwordReset: true } });
// Confirm in place rather than bouncing to /login: the old redirect
// passed `passwordReset: true` in route state that no page ever read, so
// the account holder landed on a bare sign-in form with no sign the reset
// had worked — and the link is single-use, so there is no way back to
// check.
setSucceeded(true);
} catch (err) {
setError(extractApiError(err).message);
} finally {
@@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() {
}
};
// Password set — the link is now spent, so this is the last thing the account
// holder sees. It replaces the whole form rather than sitting above it.
if (succeeded) {
return (
<AuthShell
tagline="Password updated"
taglineBody="Your EDR Freight account is ready to use."
>
<div className="flex w-full flex-col">
<div className="mb-1 flex justify-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckCircle2 size={30} strokeWidth={2.2} />
</span>
</div>
<div className="mb-6 mt-4 space-y-2 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
You&apos;re all set
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Your password has been updated
{account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}.
Sign in with your new password to continue.
</p>
</div>
<Stack gap="md">
<Button
color="edr-green"
size="md"
fullWidth
leftSection={<LogIn size={18} />}
onClick={() =>
navigate("/login", {
replace: true,
state: { passwordReset: true },
})
}
>
Go to sign in
</Button>
<p className="text-center text-xs leading-relaxed text-gray-400">
For your security, this reset link has now been used and will not
work again.
</p>
</Stack>
</div>
</AuthShell>
);
}
return (
<AuthShell
tagline="Set a new password"

View File

@@ -0,0 +1,17 @@
import { Package } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
/**
* Shipping-line bookings. Contracts do not apply to shipping lines, so a
* booking is requested directly here rather than being created against a
* contract the way the customer flow does it.
*/
export default function ShippingLineBookingsPage() {
return (
<ShippingLinePlaceholder
title="Bookings"
description="Request and track your booking requests."
icon={<Package size={28} className="text-slate-300" />}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { LifeBuoy } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
/**
* Shipping-line help & support. Separate from the customer `/help` page, which
* is public (the auth screens link to it) and carries its own doc chrome; this
* one lives inside the shipping-line app layout and will hold guidance written
* for shipping lines.
*/
export default function ShippingLineHelpPage() {
return (
<ShippingLinePlaceholder
title="Help & Support"
description="Guides and support for shipping lines."
icon={<LifeBuoy size={28} className="text-slate-300" />}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { Home } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
/**
* Shipping-line home / dashboard. Deliberately separate from the customer
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
* profiles and no contracts, so almost none of that page's data applies.
*/
export default function ShippingLineHomePage() {
return (
<ShippingLinePlaceholder
title="Home"
description="Overview of your shipping-line activity."
icon={<Home size={28} className="text-slate-300" />}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { Receipt } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
/** Shipping-line payments / invoices. */
export default function ShippingLineInvoicesPage() {
return (
<ShippingLinePlaceholder
title="Payments"
description="Your invoices and payment history."
icon={<Receipt size={28} className="text-slate-300" />}
/>
);
}

View File

@@ -0,0 +1,40 @@
import { Card, Stack, Text, Title } from "@mantine/core";
import type { ReactNode } from "react";
export interface ShippingLinePlaceholderProps {
title: string;
description: string;
icon?: ReactNode;
}
/**
* Shell for the shipping-line pages while they are still being built out.
* Each page owns its own file so it can be filled in independently; this only
* supplies the shared empty-state chrome and is meant to be deleted from a page
* once that page has real content.
*/
export default function ShippingLinePlaceholder({
title,
description,
icon,
}: ShippingLinePlaceholderProps) {
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>{title}</Title>
<Text c="dimmed" size="sm">
{description}
</Text>
</Stack>
<Card withBorder radius="md" py={64}>
<Stack align="center" gap="xs">
{icon}
<Text c="dimmed" size="sm">
Nothing here yet this page is still being built.
</Text>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,17 @@
import { Settings } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
/**
* Shipping-line settings. Separate from the customer `SettingsPage`, which is
* built around company profiles, business licenses and contact-person review —
* none of which a shipping line has.
*/
export default function ShippingLineSettingsPage() {
return (
<ShippingLinePlaceholder
title="Settings"
description="Manage your account and preferences."
icon={<Settings size={28} className="text-slate-300" />}
/>
);
}

View File

@@ -0,0 +1,5 @@
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";

View File

@@ -89,7 +89,18 @@ export interface CompanyProfileResponse {
updatedAt: string;
}
/**
* Which kind of account is signed in.
*
* Read this instead of inferring from a missing `company`: a failed or
* in-flight company fetch also leaves `company` empty, and treating that as
* "shipping line" would skip onboarding for customers whenever the request
* failed. Absent (older responses) means `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
export interface CompanyInfoResponse {
accountKind?: AccountKind;
profile: ExternalProfileResponse;
company: CompanyResponse;
/**
@@ -104,6 +115,30 @@ export interface CompanyInfoResponse {
} | null;
}
/**
* A signed-in shipping line. It has no company, no external profile and no
* onboarding — the carrier record itself is the account.
*/
export interface ShippingLineInfoResponse {
accountKind: "shipping_line";
id: string;
name: string;
email: string;
phoneNumber: string | null;
scacCode: string | null;
status: string;
company: null;
profile: null;
review: null;
}
/** `GET /companies/getInfo` serves both portal audiences. */
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
export const isShippingLineAccount = (
info: AccountInfoResponse | null | undefined,
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
/** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse {
id: string;
@@ -249,9 +284,9 @@ export interface DashboardSummary {
}
export const companiesService = {
getInfo: async (): Promise<CompanyInfoResponse | null> => {
getInfo: async (): Promise<AccountInfoResponse | null> => {
try {
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
const response = await client.get<ApiResponse<AccountInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.GET_INFO,
);
return unwrap(response.data);