feat(onboarding): Introduce multi-step customer onboarding page, enhancing AuthLayout with dynamic class props and improving customer service for typed creation and graceful 404 handling.

This commit is contained in:
ghost2023
2026-05-28 15:53:57 +03:00
parent 0eaa1f93f3
commit 3e77f27a96
3 changed files with 555 additions and 17 deletions

View File

@@ -1,8 +1,11 @@
import type { ReactNode } from "react";
import { ShieldCheck, Train } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AuthLayoutProps {
children: ReactNode;
parentClassName?: string;
contentClassName?: string;
left: {
badge: string;
title: string;
@@ -17,10 +20,15 @@ export interface AuthLayoutProps {
};
}
export default function AuthLayout({ children, left }: AuthLayoutProps) {
export default function AuthLayout({
children,
parentClassName,
contentClassName,
left,
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
@@ -58,8 +66,13 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
</div>
</div>
</div>
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-md">
<div
className={cn(
"flex items-center justify-center p-6 md:p-10",
contentClassName,
)}
>
<div className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />

View File

@@ -0,0 +1,520 @@
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";
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 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 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%]",
},
}}
>
<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

@@ -7,6 +7,7 @@ import type {
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import { isAxiosError } from "axios";
const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
@@ -23,22 +24,26 @@ export const customersService = {
return unwrap(response.data);
},
getByUserId: async (userId: string): Promise<Customer> => {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
getByUserId: async (userId: string): Promise<Customer | null> => {
try {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (payload: CreateCustomerDto): Promise<Customer> => {
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
return unwrap(response.data);
},
create: async (payload: any): Promise<any> => {
const response = await client.post<ApiResponse<any>>(BASE, payload);
return unwrap(response.data);
},
update: async (
id: string,
payload: UpdateCustomerDto,
): Promise<Customer> => {
update: async (id: string, payload: UpdateCustomerDto): Promise<Customer> => {
const response = await client.patch<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
payload,