customer registration

This commit is contained in:
yaschalew
2026-05-30 09:47:26 +03:00
parent b8f6438371
commit 1b8391186d
5 changed files with 2147 additions and 0 deletions

View File

@@ -32,6 +32,8 @@ import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import { useEffect } from "react";
import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoarding";
import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage";
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/", icon: <Home /> },
@@ -77,6 +79,8 @@ const App = () => {
return <OnboardingPage />;
}
return <CustomerOnboardingPage />
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;

View File

@@ -0,0 +1,527 @@
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,
Building2,
User,
FileText,
CheckCircle2,
Loader2,
} 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 TransporterOnboarding from "./TransportrOnBoarding";
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
import ImportExportOnBoarding from "./ImportExportOnBoarding";
type OnboardingStep = "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<OnboardingStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
};
export default function CustomerOnboardingPage() {
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 createCustomerMutation = useMutation({
mutationFn: (payload: CreateCustomerDto) =>
api.customers.create.call(payload),
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
});
},
});
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");
};
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,
};
createCustomerMutation.mutate(payload);
};
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%]",
},
}}
>
<TransporterOnboarding />
{/* <DjiboutiForwardingAgentForm /> */}
{/* <ImportExportOnBoarding /> */}
{/* <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>
);
}
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>
);
}

View File

@@ -0,0 +1,558 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "representative";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z
.string()
.min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
// COMPANY
companyName: z
.string()
.min(1, "Company name is required"),
companyEmail: z
.string()
.email("Invalid company email"),
companyPhone: z
.string()
.min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1),
companyLocation: z
.string()
.min(1, "Company location is required"),
companyAddress: z
.string()
.min(1, "Company address is required"),
// REPRESENTATIVE
representativeName: z
.string()
.min(1, "Representative name is required"),
representativeEmail: z
.string()
.email("Invalid representative email"),
representativePhone: z
.string()
.min(1, "Representative phone is required"),
representativePhoneCountryCode: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
representative: [
"representativeName",
"representativeEmail",
"representativePhone",
"representativePhoneCountryCode",
],
};
export default function DjiboutiForwardingAgentForm() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+253",
companyPhoneCountryCode: "+253",
representativePhoneCountryCode:
"+253",
},
});
const nextStep = async () => {
if (step === "representative") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(
stepFields[step]
);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else {
setStep("representative");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (
step === "representative"
) {
setStep("company");
}
};
const onSubmit = async (
data: FormData
) => {
console.log(data);
};
return (
<>
{/* STEPPER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-xl 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={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={
<Building2 className="size-5" />
}
active={step === "company"}
completed={
step === "representative"
}
/>
<StepIcon
icon={<User className="size-5" />}
active={
step === "representative"
}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" &&
"Step 1 of 3 — Personal Information"}
{step === "company" &&
"Step 2 of 3 — Company Information"}
{step === "representative" &&
"Step 3 of 3 — Representative Information"}
</p>
</div>
{/* FORM */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="Ahmed"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Ali"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="agent@company.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<PhoneInput
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "77123456",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Djibouti Freight Co."
{...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"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<PhoneInput
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location / Country
</FieldLabel>
<Input
placeholder="Djibouti"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Rue de Venise"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
</>
)}
{/* REPRESENTATIVE */}
{step ===
"representative" && (
<>
<Field
data-invalid={Boolean(
errors.representativeName
)}
>
<FieldLabel>
Company Representative Person
Name
</FieldLabel>
<Input
placeholder="Mohamed Hassan"
{...register(
"representativeName"
)}
/>
<FieldError
errors={[
errors.representativeName,
]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.representativeEmail
)}
>
<FieldLabel>
Representative Email
</FieldLabel>
<Input
type="email"
placeholder="rep@company.com"
{...register(
"representativeEmail"
)}
/>
<FieldError
errors={[
errors.representativeEmail,
]}
/>
</Field>
<PhoneInput
label="Representative Phone"
countryCode={{
...register(
"representativePhoneCountryCode"
),
}}
phone={{
...register(
"representativePhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.representativePhoneCountryCode
}
phoneError={
errors.representativePhone
}
/>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{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>
);
}

View File

@@ -0,0 +1,771 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
FileText,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "personnel"
| "poa";
const onboardingSchema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z.string().min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
// COMPANY
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),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
// LEGAL
tinNumber: z.string().regex(/^\d{10}$/, {
message: "TIN must be exactly 10 digits",
}),
vatNumber: z.string().min(1, "VAT number is required"),
fanNumber: z.string().regex(/^\d{16}$/, {
message: "FAN must be exactly 16 digits",
}),
// CONTACT PERSON
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),
// GENERAL MANAGER
generalManagerName: z
.string()
.min(1, "General manager name is required"),
generalManagerEmail: z
.string()
.email("Invalid email"),
generalManagerPhone: z
.string()
.min(1, "General manager phone is required"),
generalManagerPhoneCountryCode: z.string().min(1),
// OPTIONAL POA
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<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
};
export default function ImportExportOnBoarding() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
phoneCountryCode: "+251",
companyPhoneCountryCode: "+251",
contactPersonPhoneCountryCode: "+251",
generalManagerPhoneCountryCode: "+251",
poaPhoneCountryCode: "+251",
},
});
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else if (step === "company") {
setStep("personnel");
} else {
setStep("poa");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (step === "personnel") {
setStep("company");
} else if (step === "poa") {
setStep("personnel");
}
};
const onSubmit = async (data: FormData) => {
console.log(data);
};
return (
<>
{/* STEP HEADER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-2xl 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={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={
step === "personnel" ||
step === "poa"
}
/>
<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 === "personal" &&
"Step 1 of 4 — Personal Information"}
{step === "company" &&
"Step 2 of 4 — Company Information"}
{step === "personnel" &&
"Step 3 of 4 — Personnel Information"}
{step === "poa" &&
"Step 4 of 4 — Power of Attorney"}
</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="John"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Doe"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<PhoneInput
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "912345678",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
{...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"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<PhoneInput
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location
</FieldLabel>
<Input
placeholder="Addis Ababa"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Bole, Woreda 03"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field
data-invalid={Boolean(
errors.tinNumber
)}
>
<FieldLabel>
TIN Number
</FieldLabel>
<Input
maxLength={10}
placeholder="1234567890"
{...register("tinNumber")}
/>
<FieldError
errors={[errors.tinNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.vatNumber
)}
>
<FieldLabel>
VAT Number
</FieldLabel>
<Input
placeholder="VAT123456"
{...register("vatNumber")}
/>
<FieldError
errors={[errors.vatNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.fanNumber
)}
>
<FieldLabel>
FAN Number
</FieldLabel>
<Input
maxLength={16}
placeholder="1234567890123456"
{...register("fanNumber")}
/>
<FieldError
errors={[errors.fanNumber]}
/>
</Field>
</div>
</>
)}
{/* PERSONNEL */}
{step === "personnel" && (
<>
<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>
Contact Person Name
</FieldLabel>
<Input
placeholder="Jane Smith"
{...register(
"contactPersonName"
)}
/>
<FieldError
errors={[
errors.contactPersonName,
]}
/>
</Field>
<PhoneInput
label="Contact Person Phone"
countryCode={{
...register(
"contactPersonPhoneCountryCode"
),
}}
phone={{
...register(
"contactPersonPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.contactPersonPhoneCountryCode
}
phoneError={
errors.contactPersonPhone
}
/>
</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>
General Manager Name
</FieldLabel>
<Input
placeholder="Abebe Bikila"
{...register(
"generalManagerName"
)}
/>
<FieldError
errors={[
errors.generalManagerName,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.generalManagerEmail
)}
>
<FieldLabel>
General Manager Email
</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
{...register(
"generalManagerEmail"
)}
/>
<FieldError
errors={[
errors.generalManagerEmail,
]}
/>
</Field>
<PhoneInput
label="General Manager Phone"
countryCode={{
...register(
"generalManagerPhoneCountryCode"
),
}}
phone={{
...register(
"generalManagerPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.generalManagerPhoneCountryCode
}
phoneError={
errors.generalManagerPhone
}
/>
</div>
</div>
</>
)}
{/* POA */}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are
optional.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative"
{...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
label="PoA Phone"
countryCode={{
...register(
"poaPhoneCountryCode"
),
}}
phone={{
...register("poaPhone"),
placeholder: "912345678",
}}
/>
</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>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{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>
);
}

View File

@@ -0,0 +1,287 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
User,
Truck,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type Step = "personal" | "transport";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phoneNumber: z.string().min(1),
phoneCountryCode: z.string().min(1),
// TRANSPORT
fanNumber: z.string().min(1),
tinNumber: z.string().min(1),
truckType: z.enum([
"Casoni",
"Truck Trailer",
"High Bed",
"Low Bed",
"Others",
]),
plateNumber: z.string().min(1),
plateNumber2: z.string().optional(),
vehicleModel: z.string().min(1),
yearOfManufacturing: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<Step, (keyof FormData)[]> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
transport: [
"fanNumber",
"tinNumber",
"truckType",
"plateNumber",
"plateNumber2",
"vehicleModel",
"yearOfManufacturing",
],
};
export default function TransporterOnboarding() {
const [step, setStep] = useState<Step>("personal");
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+251",
},
});
const truckType = watch("truckType");
const nextStep = async () => {
const valid = await trigger(stepFields[step]);
if (!valid) return;
if (step === "personal") setStep("transport");
else handleSubmit(onSubmit)();
};
const prevStep = () => {
if (step === "transport") setStep("personal");
};
const onSubmit = (data: FormData) => {
console.log("TRANSPORTER:", data);
};
return (
<>
{/* STEPPER */}
<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={<User className="size-5" />}
active={step === "personal"}
completed={step !== "personal"}
/>
<StepIcon
icon={<Truck className="size-5" />}
active={step === "transport"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" && "Step 1 of 2 — Personal Information"}
{step === "transport" && "Step 2 of 2 — Transport Information"}
</p>
</div>
{/* FORM */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.firstName}>
<FieldLabel>First Name</FieldLabel>
<Input {...register("firstName")} />
<FieldError errors={[errors.firstName]} />
</Field>
<Field data-invalid={!!errors.lastName}>
<FieldLabel>Last Name</FieldLabel>
<Input {...register("lastName")} />
<FieldError errors={[errors.lastName]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input type="email" {...register("email")} />
<FieldError errors={[errors.email]} />
</Field>
<PhoneInput
label="Phone Number"
countryCode={{ ...register("phoneCountryCode") }}
phone={{ ...register("phoneNumber") }}
countryCodeError={errors.phoneCountryCode}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* TRANSPORT */}
{step === "transport" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.fanNumber}>
<FieldLabel>FAN Number</FieldLabel>
<Input {...register("fanNumber")} />
<FieldError errors={[errors.fanNumber]} />
</Field>
<Field data-invalid={!!errors.tinNumber}>
<FieldLabel>TIN Number</FieldLabel>
<Input {...register("tinNumber")} />
<FieldError errors={[errors.tinNumber]} />
</Field>
</div>
<Field data-invalid={!!errors.truckType}>
<FieldLabel>Truck Type</FieldLabel>
<select
className="w-full border rounded-md p-2 bg-background"
{...register("truckType")}
>
<option value="">Select type</option>
<option value="Casoni">Casoni</option>
<option value="Truck Trailer">Truck Trailer</option>
<option value="High Bed">High Bed</option>
<option value="Low Bed">Low Bed</option>
<option value="Others">Others</option>
</select>
<FieldError errors={[errors.truckType]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.plateNumber}>
<FieldLabel>Plate Number</FieldLabel>
<Input {...register("plateNumber")} />
<FieldError errors={[errors.plateNumber]} />
</Field>
{truckType === "Casoni" && (
<Field data-invalid={!!errors.plateNumber2}>
<FieldLabel>Second Plate Number (Casoni)</FieldLabel>
<Input {...register("plateNumber2")} />
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.vehicleModel}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input {...register("vehicleModel")} />
<FieldError errors={[errors.vehicleModel]} />
</Field>
<Field data-invalid={!!errors.yearOfManufacturing}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input {...register("yearOfManufacturing")} />
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep} disabled={step === "personal"}>
<ArrowLeft />
Back
</Button>
<Button type="button" onClick={nextStep} disabled={isSubmitting}>
{step === "transport" ? (
"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>
);
}