feat: setup the ui for document uploading in accounts onboarding

This commit is contained in:
ghost2023
2026-06-04 14:30:49 +03:00
parent d61c3813cb
commit 8ddd166dca
5 changed files with 981 additions and 391 deletions

View File

@@ -14,10 +14,8 @@ import {
ChevronLeft,
UploadCloud,
} from "lucide-react";
import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -30,7 +28,7 @@ import {
} from "@edr/ui-common";
import { api } from "@/services/api";
type CompanyStep = "company" | "personnel" | "poa" | "documents";
type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -85,6 +83,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
],
poa: [],
documents: [],
confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -116,13 +115,13 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function CompanyProfileForm({
userType,
documentSettingCode,
user,
onSubmit,
isPending,
onBack,
}: {
userType: OnboardingUserType;
documentSettingCode: string;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
@@ -133,9 +132,9 @@ export default function CompanyProfileForm({
Record<string, File | File[] | null>
>({});
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
@@ -144,6 +143,7 @@ export default function CompanyProfileForm({
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
@@ -173,18 +173,20 @@ export default function CompanyProfileForm({
},
});
const hasDocuments = uploadSettings.length > 0;
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
if (hasDocuments) {
setStep("documents");
} else {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
}
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -201,8 +203,10 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
} else {
} else if (step === "documents") {
setStep("poa");
} else {
setStep("documents");
}
};
@@ -228,31 +232,41 @@ export default function CompanyProfileForm({
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
completed={
step === "poa" || step === "documents" || step === "confirm"
}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={hasDocuments ? step === "documents" : step === "personnel"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
{hasDocuments && (
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={false}
/>
)}
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
{step === "company" &&
`Step 1 of ${totalSteps} — Company Information`}
{step === "personnel" &&
`Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
@@ -353,11 +367,6 @@ export default function CompanyProfileForm({
{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
@@ -500,62 +509,155 @@ export default function CompanyProfileForm({
{step === "documents" && (
<>
<p className="text-sm text-muted-foreground">
Upload required documents for your registration. You can skip
this step and upload later from your account settings.
</p>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : uploadSettings.length === 0 ? (
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
{uploadSettings.map((setting) => (
<SmartFileInput
key={setting.id}
file={setting}
value={documentFiles}
onChange={setDocumentFiles}
/>
))}
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow
label="Company name"
value={formValues.companyName}
/>
<ReviewRow
label="Company email"
value={formValues.companyEmail}
/>
<ReviewRow
label="Company phone"
value={formValues.companyPhone}
/>
<ReviewRow
label="Location"
value={formValues.companyLocation}
/>
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company" ? "Change Type" : "Back"}
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="button" onClick={nextStep} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
<div className="flex items-center gap-3">
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -10,6 +11,7 @@ import {
CheckCircle2,
Loader2,
ChevronLeft,
UploadCloud,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -21,9 +23,11 @@ import {
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
} from "@edr/ui-common";
import { api } from "@/services/api";
type DjiboutiStep = "company" | "representative";
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -40,9 +44,23 @@ const djiboutiSchema = z.object({
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",
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
representative: [
"repName",
"repEmail",
"repPhone",
"repPhoneCountryCode",
],
documents: [],
confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -64,22 +82,35 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function DjiboutiAgentForm({
documentSettingCode,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<DjiboutiStep>("company");
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(djiboutiSchema),
@@ -97,32 +128,42 @@ export default function DjiboutiAgentForm({
},
});
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 4;
const nextStep = async () => {
if (step === "representative") {
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] =
step === "company"
? [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
]
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
const fields = stepFields[step];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("representative");
};
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => {
if (step === "company") {
onBack();
} else {
} else if (step === "representative") {
setStep("company");
} else if (step === "documents") {
setStep("representative");
} else {
setStep("documents");
}
};
@@ -143,21 +184,34 @@ export default function DjiboutiAgentForm({
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step === "representative"}
completed={step !== "company"}
/>
<StepIcon
icon={<UserRound className="size-5" />}
active={step === "representative"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{stepLabels[step]}
{step === "company" && `Step 1 of ${totalSteps} — Company Information`}
{step === "representative" && `Step 2 of ${totalSteps} — Representative Details`}
{step === "documents" && `Step 3 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 4 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
@@ -262,35 +316,120 @@ export default function DjiboutiAgentForm({
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow
label="Rep. phone"
value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company" ? "Change Type" : "Back"}
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="button" onClick={nextStep} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "representative" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,11 +11,11 @@ import {
FileText,
CheckCircle2,
Loader2,
ChevronLeft,
UploadCloud,
} 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 type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -24,14 +24,13 @@ import {
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
} from "@edr/ui-common";
import TransporterOnboarding from "./TransportrOnBoarding";
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
import ImportExportOnBoarding from "./ImportExportOnBoarding";
import { api } from "@/services/api";
type OnboardingStep = "company" | "personnel" | "poa";
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
const forwarderSchema = 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"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
@@ -83,20 +82,71 @@ const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
"generalManagerPhoneCountryCode",
],
poa: [],
documents: [],
confirm: [],
};
export default function CustomerOnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [step, setStep] = useState<OnboardingStep>("company");
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
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 ForwarderForm({
documentSettingCode,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -123,20 +173,21 @@ export default function CustomerOnboardingPage() {
},
});
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 formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields = stepFields[step];
@@ -145,69 +196,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa");
};
const prevStep = () => {
if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
const skipDocuments = () => {
setStep("confirm");
};
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);
const prevStep = () => {
if (step === "company") {
onBack();
} else if (step === "personnel") {
setStep("company");
} else if (step === "poa") {
setStep("personnel");
} else if (step === "documents") {
setStep("poa");
} else {
setStep("documents");
}
};
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">
<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>
<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
@@ -218,22 +236,41 @@ export default function CustomerOnboardingPage() {
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
completed={step === "poa" || step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
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)"}
{step === "company" &&
`Step 1 of ${totalSteps} — Company Information`}
{step === "personnel" &&
`Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p>
</div> */}
</div>
{/* <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{step === "company" && (
<>
@@ -332,11 +369,6 @@ export default function CustomerOnboardingPage() {
{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
@@ -418,88 +450,212 @@ export default function CustomerOnboardingPage() {
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable.
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p>
<Field>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Name</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>
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</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"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
Back
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button
type="button"
onClick={nextStep}
disabled={createCustomerMutation.isPending}
>
{createCustomerMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form> */}
</AuthLayout>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
ArrowUpFromLine,
@@ -13,6 +13,7 @@ import { api } from "@/services/api";
import type { CreateCompanyPayload } from "@/services/companies.service";
import AuthLayout from "@/components/auth/AuthLayout";
import CompanyProfileForm from "./CompanyProfileForm";
import ForwarderForm from "./ForwarderForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm";
import TransporterForm from "./TransporterForm";
import type { OnboardingUserType } from "./types";
@@ -113,18 +114,19 @@ const PREFLIGHT_LEFT = {
},
};
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
importer: "company_onboarding_documents_customer",
exporter: "company_onboarding_documents_customer",
"freight-forwarder-et": "company_onboarding_documents_forwarder",
"freight-forwarder-dj": "company_onboarding_documents_forwarder_dj",
transporter: "company_onboarding_documents_transporter",
};
export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
refetchOnMount: false,
}),
);
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer",
exporter: "customer",
@@ -237,6 +239,7 @@ export default function OnboardingPage() {
<AuthLayout left={leftProps}>
{userType === "transporter" ? (
<TransporterForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
@@ -244,6 +247,15 @@ export default function OnboardingPage() {
/>
) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-et" ? (
<ForwarderForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
@@ -251,7 +263,7 @@ export default function OnboardingPage() {
/>
) : (
<CompanyProfileForm
userType={userType}
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}

View File

@@ -1,11 +1,16 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Loader2,
ArrowRight,
ArrowLeft,
ChevronLeft,
Truck,
Info,
CheckCircle2,
Loader2,
UploadCloud,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -20,8 +25,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
SmartFileInput,
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
import { api } from "@/services/api";
const TRUCK_TYPES = [
"Casoni",
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
"Others",
] as const;
type TransporterStep = "vehicle" | "documents" | "confirm";
const transporterSchema = z
.object({
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -45,7 +54,10 @@ const transporterSchema = z
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
})
.superRefine((data, ctx) => {
if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) {
if (
data.truckType === "Casoni" &&
(!data.plateNumber2 || data.plateNumber2.trim().length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["plateNumber2"],
@@ -77,19 +89,34 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
}
export default function TransporterForm({
documentSettingCode,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<TransporterStep>("vehicle");
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
control,
formState: { errors },
@@ -108,187 +135,341 @@ export default function TransporterForm({
const truckType = watch("truckType");
const isCasoni = truckType === "Casoni";
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 3;
const nextStep = async () => {
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] = [
"tinNumber",
"fanNumber",
"truckType",
"plateNumber",
"vehicleModel",
"yearOfManufacturing",
];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("documents");
};
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => {
if (step === "vehicle") {
onBack();
} else if (step === "documents") {
setStep("vehicle");
} else {
setStep("documents");
}
};
return (
<>
<div className="mb-8">
<button
type="button"
onClick={onBack}
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-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 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={<Truck className="size-5" />}
active={step === "vehicle"}
completed={step !== "vehicle"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
Transporter Registration
{step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`}
{step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
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>
{step === "vehicle" && (
<>
<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>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && (
<ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />
)}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
</div>
</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 type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "vehicle"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : (
"Complete Registration"
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
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>
);
}