mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #59 from Tria-plc/freight/feature/registeration
freight/feature/registeration
This commit is contained in:
@@ -4,13 +4,13 @@ import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
Outlet,
|
||||
} from "react-router-dom";
|
||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||
import {
|
||||
CalendarCheck,
|
||||
MapPin,
|
||||
Receipt,
|
||||
FileText,
|
||||
Home,
|
||||
Loader2,
|
||||
User,
|
||||
@@ -36,7 +36,7 @@ import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoardi
|
||||
import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage";
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/", icon: <Home /> },
|
||||
{ label: "Home", href: "/portal", icon: <Home /> },
|
||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||
@@ -46,13 +46,21 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, isPending, logout, customer, customerQuery } = useAuth();
|
||||
|
||||
console.log({ customer, isPending, user });
|
||||
const { user, isPending, logout, customer } = useAuth();
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
// if (!user.hasSetPassword) navigate("/set-password");
|
||||
}, [user]);
|
||||
if (isPending) return;
|
||||
const isInProtectedRoutes = sidebarItems.find((item) =>
|
||||
location.pathname.startsWith(item.href),
|
||||
);
|
||||
if (!user) {
|
||||
if (isInProtectedRoutes) return navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (user && location.pathname === "/") navigate("/portal");
|
||||
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
|
||||
else if (customer && !isInProtectedRoutes) return navigate("/portal");
|
||||
}, [user, location, customer]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -62,50 +70,45 @@ const App = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<EDRFreightLandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !customer && !customerQuery.isPending) {
|
||||
return <OnboardingPage />;
|
||||
}
|
||||
|
||||
return <CustomerOnboardingPage />
|
||||
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
const userEmail = user?.email;
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
onLogout={logout}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MyPortalPage />} />
|
||||
<Routes>
|
||||
<Route>
|
||||
<Route index element={<EDRFreightLandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
<DashboardLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
onLogout={logout}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "@/types/auth";
|
||||
import type { Result } from "@/utils/result";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
const expires = new Date();
|
||||
@@ -44,6 +45,16 @@ const useAuth = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({
|
||||
user: authQuery.data,
|
||||
customer: customerQuery.data,
|
||||
isCustomer: !!customerQuery.data,
|
||||
isUserPending: authQuery.isPending,
|
||||
isCustomerPending: customerQuery.isPending,
|
||||
});
|
||||
}, [authQuery, customerQuery]);
|
||||
|
||||
const hasToken = !!getCookie("auth-token");
|
||||
const isPending = authQuery.isPending && hasToken;
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import type { OnboardingUserType } from "./types";
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCustomerDto } from "@/types/customers";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type CompanyStep = "company" | "personnel" | "poa";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
generalManagerName: z.string().min(1, "GM name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
||||
generalManagerPhone: z.string().min(1, "GM phone is required"),
|
||||
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z.string().optional(),
|
||||
poaPhoneCountryCode: z.string().optional(),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
],
|
||||
personnel: [
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"contactPersonPhoneCountryCode",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
"generalManagerPhoneCountryCode",
|
||||
],
|
||||
poa: [],
|
||||
};
|
||||
|
||||
const POA_FIELDS: (keyof FormData)[] = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaPhoneCountryCode",
|
||||
"poaAddress",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
];
|
||||
|
||||
const POA_LABELS: Record<string, string> = {
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaPhoneCountryCode: "PoA country code",
|
||||
poaAddress: "PoA address",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
};
|
||||
|
||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
return {
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email,
|
||||
phone: user.phoneNumber,
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
|
||||
tinNumber: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone:
|
||||
data.poaPhone && data.poaPhoneCountryCode
|
||||
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||
: undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
userType,
|
||||
user,
|
||||
onSubmit,
|
||||
isPending,
|
||||
onBack,
|
||||
}: {
|
||||
userType: OnboardingUserType;
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCustomerDto) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const requirePoA = userType === "freight-forwarder-et";
|
||||
|
||||
const [step, setStep] = useState<CompanyStep>("company");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+251",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPhone: "",
|
||||
contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaPhoneCountryCode: "+251",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
poaLocation: "",
|
||||
},
|
||||
});
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "poa") {
|
||||
if (requirePoA) {
|
||||
clearErrors(POA_FIELDS);
|
||||
const values = getValues();
|
||||
let hasError = false;
|
||||
for (const field of POA_FIELDS) {
|
||||
const val = values[field];
|
||||
if (!val || val.toString().trim().length === 0) {
|
||||
setError(field, {
|
||||
message: `${
|
||||
POA_LABELS[field].charAt(0).toUpperCase() +
|
||||
POA_LABELS[field].slice(1)
|
||||
} is required for Freight Forwarders`,
|
||||
});
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
if (hasError) return;
|
||||
}
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
const fields = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (!isValid) return;
|
||||
setStep(step === "company" ? "personnel" : "poa");
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "company") {
|
||||
onBack();
|
||||
} else if (step === "personnel") {
|
||||
setStep("company");
|
||||
} else if (step === "poa") {
|
||||
setStep("personnel");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Change account type
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step !== "company"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<User className="size-5" />}
|
||||
active={step === "personnel"}
|
||||
completed={step === "poa"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<FileText className="size-5" />}
|
||||
active={step === "poa"}
|
||||
completed={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||
{step === "company" && "Step 1 of 3 — Company Information"}
|
||||
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
|
||||
{step === "poa" &&
|
||||
`Step 3 of 3 — Power of Attorney ${requirePoA ? "(Required)" : "(Optional)"}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Global Logistics Ltd"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location</FieldLabel>
|
||||
<Input
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.vatNumber)}>
|
||||
<FieldLabel>VAT Number</FieldLabel>
|
||||
<Input
|
||||
placeholder="VAT-12345"
|
||||
aria-invalid={Boolean(errors.vatNumber)}
|
||||
maxLength={10}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.vatNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personal details are pulled from your account. Contact and
|
||||
management info is collected below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Contact Person
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.contactPersonName)}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Jane Smith"
|
||||
aria-invalid={Boolean(errors.contactPersonName)}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<FieldError errors={[errors.contactPersonName]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("contactPersonPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("contactPersonPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
General Manager
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
className="col-span-2"
|
||||
data-invalid={Boolean(errors.generalManagerName)}
|
||||
>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Abebe Bikila"
|
||||
aria-invalid={Boolean(errors.generalManagerName)}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
aria-invalid={Boolean(errors.generalManagerEmail)}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("generalManagerPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("generalManagerPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{requirePoA
|
||||
? "Power of Attorney details are required for Freight Forwarder registration."
|
||||
: "Power of Attorney details are optional. Skip if not applicable."}
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(errors.poaName)}>
|
||||
<FieldLabel>
|
||||
PoA Name
|
||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
placeholder="Authorized Representative Name"
|
||||
aria-invalid={Boolean(errors.poaName)}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.poaEmail)}>
|
||||
<FieldLabel>
|
||||
PoA Email
|
||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
aria-invalid={Boolean(errors.poaEmail)}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||
label={`PoA Phone${requirePoA ? " *" : ""}`}
|
||||
countryCodeError={errors.poaPhoneCountryCode}
|
||||
phoneError={errors.poaPhone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.poaLocation)}>
|
||||
<FieldLabel>
|
||||
PoA Location
|
||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
placeholder="City, Country"
|
||||
aria-invalid={Boolean(errors.poaLocation)}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.poaAddress)}>
|
||||
<FieldLabel>
|
||||
PoA Address
|
||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
placeholder="Full Address"
|
||||
aria-invalid={Boolean(errors.poaAddress)}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "company" ? "Change Type" : "Back"}
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : step === "poa" ? (
|
||||
"Complete Registration"
|
||||
) : (
|
||||
<>
|
||||
Next Step
|
||||
<ArrowRight />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({
|
||||
icon,
|
||||
active,
|
||||
completed,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
||||
completed
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: active
|
||||
? "bg-background border-primary text-primary shadow-md"
|
||||
: "bg-background border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
UserRound,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCustomerDto } from "@/types/customers";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type DjiboutiStep = "company" | "representative";
|
||||
|
||||
const djiboutiSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyLocation: z.string().min(1, "Location / Country is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
repName: z.string().min(1, "Representative name is required"),
|
||||
repEmail: z.string().email("Invalid representative email"),
|
||||
repPhone: z.string().min(1, "Representative phone is required"),
|
||||
repPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof djiboutiSchema>;
|
||||
|
||||
const stepLabels: Record<DjiboutiStep, string> = {
|
||||
company: "Step 1 of 2 — Company Information",
|
||||
representative: "Step 2 of 2 — Representative Details",
|
||||
};
|
||||
|
||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
return {
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email,
|
||||
phone: user.phoneNumber,
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
contactPersonName: data.repName,
|
||||
contactPersonPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function DjiboutiAgentForm({
|
||||
user,
|
||||
onSubmit,
|
||||
isPending,
|
||||
onBack,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCustomerDto) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<DjiboutiStep>("company");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(djiboutiSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+253",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
repName: "",
|
||||
repEmail: "",
|
||||
repPhone: "",
|
||||
repPhoneCountryCode: "+253",
|
||||
},
|
||||
});
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "representative") {
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
const fields: (keyof FormData)[] =
|
||||
step === "company"
|
||||
? [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
]
|
||||
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
|
||||
const isValid = await trigger(fields);
|
||||
if (!isValid) return;
|
||||
setStep("representative");
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "company") {
|
||||
onBack();
|
||||
} else {
|
||||
setStep("company");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Change account type
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step === "representative"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<UserRound className="size-5" />}
|
||||
active={step === "representative"}
|
||||
completed={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||
{stepLabels[step]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Djibouti Logistics SARL"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="info@djib-logistics.dj"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "12345678",
|
||||
}}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location / Country</FieldLabel>
|
||||
<Input
|
||||
placeholder="Djibouti City, Djibouti"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Boulevard de la République"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "representative" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provide the company representative details for this account.
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(errors.repName)}>
|
||||
<FieldLabel>Representative Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Ahmed Hassan"
|
||||
aria-invalid={Boolean(errors.repName)}
|
||||
{...register("repName")}
|
||||
/>
|
||||
<FieldError errors={[errors.repName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.repEmail)}>
|
||||
<FieldLabel>Representative Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ahmed@company.dj"
|
||||
aria-invalid={Boolean(errors.repEmail)}
|
||||
{...register("repEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.repEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("repPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("repPhone"),
|
||||
placeholder: "12345678",
|
||||
}}
|
||||
countryCodeError={errors.repPhoneCountryCode}
|
||||
phoneError={errors.repPhone}
|
||||
label="Representative Phone"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "company" ? "Change Type" : "Back"}
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : step === "representative" ? (
|
||||
"Complete Registration"
|
||||
) : (
|
||||
<>
|
||||
Next Step
|
||||
<ArrowRight />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({
|
||||
icon,
|
||||
active,
|
||||
completed,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
||||
completed
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: active
|
||||
? "bg-background border-primary text-primary shadow-md"
|
||||
: "bg-background border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Building2,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Ship,
|
||||
Truck,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateCustomerDto } from "@/types/customers";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
import CompanyProfileForm from "./CompanyProfileForm";
|
||||
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
||||
import TransporterForm from "./TransporterForm";
|
||||
import type { OnboardingUserType } from "./types";
|
||||
|
||||
type OnboardingStep = "company" | "personnel" | "poa";
|
||||
const USER_TYPE_CARDS: {
|
||||
id: OnboardingUserType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: "importer",
|
||||
label: "Importer",
|
||||
description: "Import goods into Ethiopia via the railway corridor.",
|
||||
icon: <ArrowDownToLine className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "exporter",
|
||||
label: "Exporter",
|
||||
description: "Export goods from Ethiopia via rail.",
|
||||
icon: <ArrowUpFromLine className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-et",
|
||||
label: "Freight Forwarder (Ethiopia)",
|
||||
description:
|
||||
"Ethiopian freight forwarding company handling client cargo.",
|
||||
icon: <Building2 className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-dj",
|
||||
label: "FF Agent (Djibouti)",
|
||||
description:
|
||||
"Djibouti-based agent coordinating cross-border logistics.",
|
||||
icon: <Ship className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "transporter",
|
||||
label: "Transporter",
|
||||
description:
|
||||
"Trucking company providing first/last-mile services.",
|
||||
icon: <Truck className="size-6" />,
|
||||
},
|
||||
];
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
generalManagerName: z.string().min(1, "GM name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
||||
generalManagerPhone: z.string().min(1, "GM phone is required"),
|
||||
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z.string().optional(),
|
||||
poaPhoneCountryCode: z.string().optional(),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
const USER_TYPE_LEFT_MAP: Record<
|
||||
OnboardingUserType,
|
||||
{
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
importer: {
|
||||
badge: "Importer Registration",
|
||||
title: "Register as an Importer",
|
||||
description:
|
||||
"Set up your company profile to manage imports, track shipments, and streamline customs clearance across the Ethiopia-Djibouti corridor.",
|
||||
},
|
||||
exporter: {
|
||||
badge: "Exporter Registration",
|
||||
title: "Register as an Exporter",
|
||||
description:
|
||||
"Set up your company profile to manage exports, coordinate outbound logistics, and access rail transport services.",
|
||||
},
|
||||
"freight-forwarder-et": {
|
||||
badge: "Freight Forwarder Registration (Ethiopia)",
|
||||
title: "Register Your Forwarding Company",
|
||||
description:
|
||||
"Complete your company profile and Power of Attorney to handle cargo on behalf of importers and exporters.",
|
||||
},
|
||||
"freight-forwarder-dj": {
|
||||
badge: "FF Agent Registration (Djibouti)",
|
||||
title: "Register as a Djibouti Agent",
|
||||
description:
|
||||
"Register your company details and representative information to coordinate cross-border freight operations.",
|
||||
},
|
||||
transporter: {
|
||||
badge: "Transporter Registration",
|
||||
title: "Register Your Transport Services",
|
||||
description:
|
||||
"Provide your vehicle and fleet details to offer first-mile and last-mile trucking services integrated with rail.",
|
||||
},
|
||||
};
|
||||
|
||||
type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
const PREFLIGHT_LEFT = {
|
||||
badge: "Get Started",
|
||||
title: "Choose your account type",
|
||||
description:
|
||||
"Select the profile that best matches your role in the logistics chain. Each account type provides a tailored onboarding experience.",
|
||||
features: [
|
||||
"Importers & Exporters",
|
||||
"Freight Forwarders (Ethiopia & Djibouti)",
|
||||
"Transporters & Fleet Operators",
|
||||
],
|
||||
personnel: [
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"contactPersonPhoneCountryCode",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
"generalManagerPhoneCountryCode",
|
||||
],
|
||||
poa: [],
|
||||
stats: {
|
||||
label: "Active Customers",
|
||||
value: "500+",
|
||||
footer: "And growing",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
};
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [step, setStep] = useState<OnboardingStep>("company");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+251",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPhone: "",
|
||||
contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaPhoneCountryCode: "+251",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
poaLocation: "",
|
||||
},
|
||||
});
|
||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||
|
||||
const createCustomerMutation = useMutation({
|
||||
mutationFn: (payload: CreateCustomerDto) =>
|
||||
@@ -131,390 +132,119 @@ export default function OnboardingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "poa") {
|
||||
handleSubmit(onSubmit)();
|
||||
return;
|
||||
}
|
||||
const fields = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (!isValid) return;
|
||||
setStep(step === "company" ? "personnel" : "poa");
|
||||
};
|
||||
if (!user) return null;
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "personnel") setStep("company");
|
||||
else if (step === "poa") setStep("personnel");
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const nameParts = (user?.name?.en ?? "").split(" ");
|
||||
const payload: CreateCustomerDto = {
|
||||
userId: user!.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user!.email,
|
||||
phone: user!.phoneNumber,
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
|
||||
tinNumber: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone:
|
||||
data.poaPhone && data.poaPhoneCountryCode
|
||||
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||
: undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
};
|
||||
const handleSubmit = (payload: CreateCustomerDto) => {
|
||||
createCustomerMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleSelectType = (type: OnboardingUserType) => {
|
||||
setUserType(type);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setUserType(null);
|
||||
};
|
||||
|
||||
// Preflight: user type selection
|
||||
if (!userType) {
|
||||
return (
|
||||
<AuthLayout left={PREFLIGHT_LEFT}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
Select Account Type
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose the account type that fits your role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{USER_TYPE_CARDS.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectType(card.id)}
|
||||
className="group relative flex flex-col items-start gap-3 rounded-xl border-2 border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-primary/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
{card.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full border-2 border-border text-transparent transition-all group-hover:border-primary group-hover:text-primary">
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the appropriate form based on user type
|
||||
const leftConfig = USER_TYPE_LEFT_MAP[userType];
|
||||
const leftProps = {
|
||||
...leftConfig,
|
||||
features:
|
||||
userType === "transporter"
|
||||
? [
|
||||
"Vehicle & fleet registration",
|
||||
"TIN & FAN verification",
|
||||
"First-mile / Last-mile eligibility",
|
||||
]
|
||||
: userType === "freight-forwarder-dj"
|
||||
? [
|
||||
"Company details",
|
||||
"Representative information",
|
||||
"Cross-border operations",
|
||||
]
|
||||
: [
|
||||
"Company registration details",
|
||||
"Contact and management personnel",
|
||||
"Power of Attorney (optional)",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Customers",
|
||||
value: "500+",
|
||||
footer: "And growing",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Complete Your Profile",
|
||||
title: "Set up your company profile",
|
||||
description:
|
||||
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
|
||||
features: [
|
||||
"Company registration details",
|
||||
"Contact and management personnel",
|
||||
"Power of Attorney (optional)",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Customers",
|
||||
value: "500+",
|
||||
footer: "And growing",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="mb-8 lg:col-span-2">
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step !== "company"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<User className="size-5" />}
|
||||
active={step === "personnel"}
|
||||
completed={step === "poa"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<FileText className="size-5" />}
|
||||
active={step === "poa"}
|
||||
completed={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||
{step === "company" && "Step 1 of 3 — Company Information"}
|
||||
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
|
||||
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FieldGroup className="gap-4">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Global Logistics Ltd"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location</FieldLabel>
|
||||
<Input
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.vatNumber)}>
|
||||
<FieldLabel>VAT Number</FieldLabel>
|
||||
<Input
|
||||
placeholder="VAT-12345"
|
||||
aria-invalid={Boolean(errors.vatNumber)}
|
||||
maxLength={10}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.vatNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personal details are pulled from your account. Contact and
|
||||
management info is collected below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Contact Person
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.contactPersonName)}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Jane Smith"
|
||||
aria-invalid={Boolean(errors.contactPersonName)}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<FieldError errors={[errors.contactPersonName]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("contactPersonPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("contactPersonPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
General Manager
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
className="col-span-2"
|
||||
data-invalid={Boolean(errors.generalManagerName)}
|
||||
>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Abebe Bikila"
|
||||
aria-invalid={Boolean(errors.generalManagerName)}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
aria-invalid={Boolean(errors.generalManagerEmail)}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("generalManagerPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("generalManagerPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Power of Attorney details are optional. Skip if not applicable.
|
||||
</p>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>PoA Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Authorized Representative Name"
|
||||
{...register("poaName")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>PoA Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>PoA Location</FieldLabel>
|
||||
<Input
|
||||
placeholder="City, Country"
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>PoA Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Full Address"
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={step === "company"}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
disabled={createCustomerMutation.isPending}
|
||||
>
|
||||
{createCustomerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : step === "poa" ? (
|
||||
"Complete Registration"
|
||||
) : (
|
||||
<>
|
||||
Next Step
|
||||
<ArrowRight />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<AuthLayout left={leftProps}>
|
||||
{userType === "transporter" ? (
|
||||
<TransporterForm
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createCustomerMutation.isPending}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : userType === "freight-forwarder-dj" ? (
|
||||
<DjiboutiAgentForm
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createCustomerMutation.isPending}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : (
|
||||
<CompanyProfileForm
|
||||
userType={userType}
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createCustomerMutation.isPending}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({
|
||||
icon,
|
||||
active,
|
||||
completed,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: active
|
||||
? "bg-background border-primary text-primary shadow-md"
|
||||
: "bg-background border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ const userSchema = z.object({
|
||||
.min(9, "Phone number is too short")
|
||||
.max(9, "Phone number is too long"),
|
||||
userType: z.string(),
|
||||
name: z.object({
|
||||
firstName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
lastName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
@@ -51,7 +55,8 @@ export default function SignupPage() {
|
||||
countryCode: "+251",
|
||||
phone: "",
|
||||
userType: userType.individual,
|
||||
name: { en: "", am: "" },
|
||||
firstName: { en: "", am: "" },
|
||||
lastName: { en: "", am: "" },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,11 +72,12 @@ export default function SignupPage() {
|
||||
username: data.email,
|
||||
phoneNumber: `${data.countryCode}${normalizedPhone}`,
|
||||
userType: data.userType,
|
||||
name: { en: data.name.en, am: data.name.am ?? "" },
|
||||
name: { en: `${data.firstName.en} ${data.lastName.en}`, am: "" },
|
||||
};
|
||||
const result = await signup(payload);
|
||||
if (result.success) {
|
||||
navigate("/otp");
|
||||
navigate("/portal");
|
||||
// navigate("/otp");
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
@@ -121,18 +127,31 @@ export default function SignupPage() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FieldGroup className="gap-4">
|
||||
<Field data-invalid={Boolean(errors.name?.en)}>
|
||||
<FieldLabel>Full Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.name?.en)}
|
||||
{...register("name.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.name?.en]} />
|
||||
</Field>
|
||||
<div className="flex gap-4">
|
||||
<Field data-invalid={Boolean(errors.firstName?.en)}>
|
||||
<FieldLabel>First Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.firstName?.en)}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.firstName?.en]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.lastName?.en)}>
|
||||
<FieldLabel>Last Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.lastName?.en)}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.lastName?.en]} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errors.email)}>
|
||||
<FieldLabel>Email Address</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
Truck,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCustomerDto } from "@/types/customers";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TRUCK_TYPES = [
|
||||
"Casoni",
|
||||
"Truck Trailer",
|
||||
"High Bed",
|
||||
"Low Bed",
|
||||
"Others",
|
||||
] as const;
|
||||
|
||||
const transporterSchema = z
|
||||
.object({
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
truckType: z.string().min(1, "Truck type is required"),
|
||||
plateNumber: z.string().min(1, "Plate number is required"),
|
||||
plateNumber2: z.string().optional(),
|
||||
vehicleModel: z.string().min(1, "Vehicle model is required"),
|
||||
yearOfManufacturing: z
|
||||
.string()
|
||||
.min(1, "Year of manufacturing is required")
|
||||
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["plateNumber2"],
|
||||
message: "Second plate number is required for Casoni",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof transporterSchema>;
|
||||
|
||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
return {
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email,
|
||||
phone: user.phoneNumber,
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPhone: "",
|
||||
tinNumber: data.tinNumber,
|
||||
vatNumber: "",
|
||||
fanNumber: data.fanNumber,
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
notes: JSON.stringify({
|
||||
truckType: data.truckType,
|
||||
plateNumber: data.plateNumber,
|
||||
plateNumber2: data.plateNumber2 || null,
|
||||
vehicleModel: data.vehicleModel,
|
||||
yearOfManufacturing: data.yearOfManufacturing,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TransporterForm({
|
||||
user,
|
||||
onSubmit,
|
||||
isPending,
|
||||
onBack,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCustomerDto) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(transporterSchema),
|
||||
defaultValues: {
|
||||
tinNumber: "",
|
||||
fanNumber: "",
|
||||
truckType: "",
|
||||
plateNumber: "",
|
||||
plateNumber2: "",
|
||||
vehicleModel: "",
|
||||
yearOfManufacturing: "",
|
||||
},
|
||||
});
|
||||
|
||||
const truckType = watch("truckType");
|
||||
const isCasoni = truckType === "Casoni";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Change account type
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center relative px-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-primary bg-background text-primary shadow-md">
|
||||
<Truck className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||
Transporter Registration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Personal Info (read-only) */}
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="size-4" />
|
||||
<span className="font-medium text-foreground">Account Holder</span>
|
||||
</div>
|
||||
<p>
|
||||
{user.name?.en} — {user.email} — {user.phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Vehicle / Truck Information
|
||||
</h3>
|
||||
|
||||
<Controller
|
||||
name="truckType"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={Boolean(fieldState.error)}>
|
||||
<FieldLabel>Truck Type</FieldLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-full",
|
||||
fieldState.error ? "border-destructive!" : "",
|
||||
)}
|
||||
aria-invalid={Boolean(fieldState.error)}
|
||||
>
|
||||
<SelectValue placeholder="Select truck type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRUCK_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.plateNumber)}>
|
||||
<FieldLabel>
|
||||
Plate Number{isCasoni ? " (Front)" : ""}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
|
||||
aria-invalid={Boolean(errors.plateNumber)}
|
||||
{...register("plateNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.plateNumber]} />
|
||||
</Field>
|
||||
|
||||
{isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.plateNumber2)}>
|
||||
<FieldLabel>Plate Number (Trailer)</FieldLabel>
|
||||
<Input
|
||||
placeholder="AA-67890"
|
||||
aria-invalid={Boolean(errors.plateNumber2)}
|
||||
{...register("plateNumber2")}
|
||||
/>
|
||||
<FieldError errors={[errors.plateNumber2]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.vehicleModel)}>
|
||||
<FieldLabel>Vehicle Model</FieldLabel>
|
||||
<Input
|
||||
placeholder="Isuzu FVR 2024"
|
||||
aria-invalid={Boolean(errors.vehicleModel)}
|
||||
{...register("vehicleModel")}
|
||||
/>
|
||||
<FieldError errors={[errors.vehicleModel]} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.vehicleModel)}>
|
||||
<FieldLabel>Vehicle Model</FieldLabel>
|
||||
<Input
|
||||
placeholder="Isuzu FVR 2024"
|
||||
aria-invalid={Boolean(errors.vehicleModel)}
|
||||
{...register("vehicleModel")}
|
||||
/>
|
||||
<FieldError errors={[errors.vehicleModel]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
|
||||
<FieldLabel>Year of Manufacturing</FieldLabel>
|
||||
<Input
|
||||
placeholder="2023"
|
||||
maxLength={4}
|
||||
aria-invalid={Boolean(errors.yearOfManufacturing)}
|
||||
{...register("yearOfManufacturing")}
|
||||
/>
|
||||
<FieldError errors={[errors.yearOfManufacturing]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<ChevronLeft />
|
||||
Change Type
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Complete Registration"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
apps/edr-freight-web/portal/src/pages/accounts/types.ts
Normal file
15
apps/edr-freight-web/portal/src/pages/accounts/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type OnboardingUserType =
|
||||
| "importer"
|
||||
| "exporter"
|
||||
| "freight-forwarder-et"
|
||||
| "freight-forwarder-dj"
|
||||
| "transporter";
|
||||
|
||||
export interface UserTypeOption {
|
||||
id: OnboardingUserType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
Reference in New Issue
Block a user