mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat: setup the ui for document uploading in accounts onboarding
This commit is contained in:
@@ -14,10 +14,8 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { OnboardingUserType } from "./types";
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -30,7 +28,7 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
type CompanyStep = "company" | "personnel" | "poa" | "documents";
|
type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
const onboardingSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -85,6 +83,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
],
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
@@ -116,13 +115,13 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
userType,
|
documentSettingCode,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
userType: OnboardingUserType;
|
documentSettingCode: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -133,9 +132,9 @@ export default function CompanyProfileForm({
|
|||||||
Record<string, File | File[] | null>
|
Record<string, File | File[] | null>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByEntity.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { entity: "customer" },
|
input: { code: documentSettingCode },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -144,6 +143,7 @@ export default function CompanyProfileForm({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") {
|
if (step === "poa") {
|
||||||
if (hasDocuments) {
|
setStep("documents");
|
||||||
setStep("documents");
|
|
||||||
} else {
|
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === "documents") {
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,8 +203,10 @@ export default function CompanyProfileForm({
|
|||||||
setStep("company");
|
setStep("company");
|
||||||
} else if (step === "poa") {
|
} else if (step === "poa") {
|
||||||
setStep("personnel");
|
setStep("personnel");
|
||||||
} else {
|
} else if (step === "documents") {
|
||||||
setStep("poa");
|
setStep("poa");
|
||||||
|
} else {
|
||||||
|
setStep("documents");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,31 +232,41 @@ export default function CompanyProfileForm({
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<User className="size-5" />}
|
icon={<User className="size-5" />}
|
||||||
active={step === "personnel"}
|
active={step === "personnel"}
|
||||||
completed={step === "poa"}
|
completed={
|
||||||
|
step === "poa" || step === "documents" || step === "confirm"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<FileText className="size-5" />}
|
icon={<FileText className="size-5" />}
|
||||||
active={step === "poa"}
|
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>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||||
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
|
{step === "company" &&
|
||||||
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
|
`Step 1 of ${totalSteps} — Company Information`}
|
||||||
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
|
{step === "personnel" &&
|
||||||
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
|
`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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<FieldGroup className="gap-4">
|
<FieldGroup className="gap-4">
|
||||||
@@ -353,11 +367,6 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||||
Contact Person
|
Contact Person
|
||||||
@@ -500,62 +509,155 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "documents" && (
|
{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 ? (
|
{loadingDocuments ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : uploadSettings.length === 0 ? (
|
) : !uploadSetting ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{uploadSettings.map((setting) => (
|
<SmartFileInput
|
||||||
<SmartFileInput
|
file={uploadSetting}
|
||||||
key={setting.id}
|
value={documentFiles}
|
||||||
file={setting}
|
onChange={setDocumentFiles}
|
||||||
value={documentFiles}
|
/>
|
||||||
onChange={setDocumentFiles}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={prevStep}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{step === "company" ? "Change Type" : "Back"}
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
<Button
|
||||||
<>
|
type="button"
|
||||||
<Loader2 className="animate-spin" />
|
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||||
Submitting...
|
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||||
</>
|
>
|
||||||
) : step === "documents" ? (
|
{isPending ? (
|
||||||
"Complete Registration"
|
<>
|
||||||
) : (
|
<Loader2 className="animate-spin" />
|
||||||
<>
|
Submitting...
|
||||||
Next Step
|
</>
|
||||||
<ArrowRight />
|
) : step === "documents" ? (
|
||||||
</>
|
"Continue"
|
||||||
)}
|
) : step === "confirm" ? (
|
||||||
</Button>
|
"Submit Registration"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Next Step
|
||||||
|
<ArrowRight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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({
|
function StepIcon({
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
@@ -21,9 +23,11 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
type DjiboutiStep = "company" | "representative";
|
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
|
||||||
|
|
||||||
const djiboutiSchema = z.object({
|
const djiboutiSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -40,9 +44,23 @@ const djiboutiSchema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof djiboutiSchema>;
|
type FormData = z.infer<typeof djiboutiSchema>;
|
||||||
|
|
||||||
const stepLabels: Record<DjiboutiStep, string> = {
|
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
|
||||||
company: "Step 1 of 2 — Company Information",
|
company: [
|
||||||
representative: "Step 2 of 2 — Representative Details",
|
"companyName",
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyPhoneCountryCode",
|
||||||
|
"companyLocation",
|
||||||
|
"companyAddress",
|
||||||
|
],
|
||||||
|
representative: [
|
||||||
|
"repName",
|
||||||
|
"repEmail",
|
||||||
|
"repPhone",
|
||||||
|
"repPhoneCountryCode",
|
||||||
|
],
|
||||||
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
@@ -64,22 +82,35 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DjiboutiAgentForm({
|
export default function DjiboutiAgentForm({
|
||||||
|
documentSettingCode,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
|
documentSettingCode: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<DjiboutiStep>("company");
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(djiboutiSchema),
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (step === "representative") {
|
if (step === "representative") {
|
||||||
|
setStep("documents");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fields: (keyof FormData)[] =
|
const fields = stepFields[step];
|
||||||
step === "company"
|
|
||||||
? [
|
|
||||||
"companyName",
|
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyPhoneCountryCode",
|
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
|
||||||
]
|
|
||||||
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
|
|
||||||
const isValid = await trigger(fields);
|
const isValid = await trigger(fields);
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setStep("representative");
|
setStep("representative");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skipDocuments = () => {
|
||||||
|
setStep("confirm");
|
||||||
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
if (step === "company") {
|
if (step === "company") {
|
||||||
onBack();
|
onBack();
|
||||||
} else {
|
} else if (step === "representative") {
|
||||||
setStep("company");
|
setStep("company");
|
||||||
|
} else if (step === "documents") {
|
||||||
|
setStep("representative");
|
||||||
|
} else {
|
||||||
|
setStep("documents");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,21 +184,34 @@ export default function DjiboutiAgentForm({
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<Building2 className="size-5" />}
|
icon={<Building2 className="size-5" />}
|
||||||
active={step === "company"}
|
active={step === "company"}
|
||||||
completed={step === "representative"}
|
completed={step !== "company"}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<UserRound className="size-5" />}
|
icon={<UserRound className="size-5" />}
|
||||||
active={step === "representative"}
|
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}
|
completed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<FieldGroup className="gap-4">
|
<FieldGroup className="gap-4">
|
||||||
@@ -262,35 +316,120 @@ export default function DjiboutiAgentForm({
|
|||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={prevStep}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{step === "company" ? "Change Type" : "Back"}
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
{step === "documents" && (
|
||||||
<>
|
<Button
|
||||||
<Loader2 className="animate-spin" />
|
type="button"
|
||||||
Submitting...
|
variant="outline"
|
||||||
</>
|
onClick={skipDocuments}
|
||||||
) : step === "representative" ? (
|
disabled={isPending}
|
||||||
"Complete Registration"
|
>
|
||||||
) : (
|
Skip for now
|
||||||
<>
|
</Button>
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</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({
|
function StepIcon({
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import { api } from "@/services/api";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { CreateCustomerDto } from "@/types/customers";
|
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -24,14 +24,13 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import TransporterOnboarding from "./TransportrOnBoarding";
|
import { api } from "@/services/api";
|
||||||
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
|
|
||||||
import ImportExportOnBoarding from "./ImportExportOnBoarding";
|
|
||||||
|
|
||||||
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"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
companyEmail: z.string().email("Invalid email address"),
|
||||||
companyPhone: z.string().min(1, "Company phone is required"),
|
companyPhone: z.string().min(1, "Company phone is required"),
|
||||||
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
|
|||||||
poaLocation: z.string().optional(),
|
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: [
|
company: [
|
||||||
"companyName",
|
"companyName",
|
||||||
"companyEmail",
|
"companyEmail",
|
||||||
@@ -83,20 +82,71 @@ const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
|
|||||||
"generalManagerPhoneCountryCode",
|
"generalManagerPhoneCountryCode",
|
||||||
],
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CustomerOnboardingPage() {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
const queryClient = useQueryClient();
|
return {
|
||||||
const { user } = useAuth();
|
companyName: data.companyName,
|
||||||
const [step, setStep] = useState<OnboardingStep>("company");
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(forwarderSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
@@ -123,20 +173,21 @@ export default function CustomerOnboardingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCustomerMutation = useMutation({
|
const formValues = watch();
|
||||||
mutationFn: (payload: CreateCustomerDto) =>
|
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||||
api.customers.create.call(payload),
|
const totalSteps = 5;
|
||||||
onSuccess: () => {
|
|
||||||
if (user)
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") {
|
if (step === "poa") {
|
||||||
handleSubmit(onSubmit)();
|
setStep("documents");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fields = stepFields[step];
|
const fields = stepFields[step];
|
||||||
@@ -145,69 +196,36 @@ export default function CustomerOnboardingPage() {
|
|||||||
setStep(step === "company" ? "personnel" : "poa");
|
setStep(step === "company" ? "personnel" : "poa");
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const skipDocuments = () => {
|
||||||
if (step === "personnel") setStep("company");
|
setStep("confirm");
|
||||||
else if (step === "poa") setStep("personnel");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const prevStep = () => {
|
||||||
const nameParts = (user?.name?.en ?? "").split(" ");
|
if (step === "company") {
|
||||||
const payload: CreateCustomerDto = {
|
onBack();
|
||||||
userId: user!.id,
|
} else if (step === "personnel") {
|
||||||
firstName: nameParts[0] || "",
|
setStep("company");
|
||||||
lastName: nameParts.slice(-1)[0] || "",
|
} else if (step === "poa") {
|
||||||
email: user!.email,
|
setStep("personnel");
|
||||||
phone: user!.phoneNumber,
|
} else if (step === "documents") {
|
||||||
companyName: data.companyName,
|
setStep("poa");
|
||||||
companyEmail: data.companyEmail,
|
} else {
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
setStep("documents");
|
||||||
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 (
|
return (
|
||||||
<AuthLayout
|
<>
|
||||||
left={{
|
<div className="mb-8">
|
||||||
badge: "Complete Your Profile",
|
<button
|
||||||
title: "Set up your company profile",
|
type="button"
|
||||||
description:
|
onClick={prevStep}
|
||||||
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
features: [
|
>
|
||||||
"Company registration details",
|
<ChevronLeft className="size-4" />
|
||||||
"Contact and management personnel",
|
Change account type
|
||||||
"Power of Attorney (optional)",
|
</button>
|
||||||
],
|
|
||||||
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="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" />
|
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||||
<StepIcon
|
<StepIcon
|
||||||
@@ -218,22 +236,41 @@ export default function CustomerOnboardingPage() {
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<User className="size-5" />}
|
icon={<User className="size-5" />}
|
||||||
active={step === "personnel"}
|
active={step === "personnel"}
|
||||||
completed={step === "poa"}
|
completed={step === "poa" || step === "documents" || step === "confirm"}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<FileText className="size-5" />}
|
icon={<FileText className="size-5" />}
|
||||||
active={step === "poa"}
|
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}
|
completed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||||
{step === "company" && "Step 1 of 3 — Company Information"}
|
{step === "company" &&
|
||||||
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
|
`Step 1 of ${totalSteps} — Company Information`}
|
||||||
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
|
{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>
|
</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">
|
<FieldGroup className="gap-4">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<>
|
<>
|
||||||
@@ -332,11 +369,6 @@ export default function CustomerOnboardingPage() {
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||||
Contact Person
|
Contact Person
|
||||||
@@ -418,88 +450,212 @@ export default function CustomerOnboardingPage() {
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaName)}>
|
||||||
<FieldLabel>PoA Name</FieldLabel>
|
<FieldLabel>PoA Name</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
|
aria-invalid={Boolean(errors.poaName)}
|
||||||
{...register("poaName")}
|
{...register("poaName")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaName]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaEmail)}>
|
||||||
<FieldLabel>PoA Email</FieldLabel>
|
<FieldLabel>PoA Email</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="poa@company.com"
|
placeholder="poa@company.com"
|
||||||
|
aria-invalid={Boolean(errors.poaEmail)}
|
||||||
{...register("poaEmail")}
|
{...register("poaEmail")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaEmail]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||||
label="PoA Phone"
|
label="PoA Phone"
|
||||||
|
countryCodeError={errors.poaPhoneCountryCode}
|
||||||
|
phoneError={errors.poaPhone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaLocation)}>
|
||||||
<FieldLabel>PoA Location</FieldLabel>
|
<FieldLabel>PoA Location</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
|
aria-invalid={Boolean(errors.poaLocation)}
|
||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaLocation]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaAddress)}>
|
||||||
<FieldLabel>PoA Address</FieldLabel>
|
<FieldLabel>PoA Address</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Full Address"
|
placeholder="Full Address"
|
||||||
|
aria-invalid={Boolean(errors.poaAddress)}
|
||||||
{...register("poaAddress")}
|
{...register("poaAddress")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaAddress]} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={prevStep}
|
|
||||||
disabled={step === "company"}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
Back
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
type="button"
|
{step === "documents" && (
|
||||||
onClick={nextStep}
|
<Button
|
||||||
disabled={createCustomerMutation.isPending}
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
{createCustomerMutation.isPending ? (
|
onClick={skipDocuments}
|
||||||
<>
|
disabled={isPending}
|
||||||
<Loader2 className="animate-spin" />
|
>
|
||||||
Submitting...
|
Skip for now
|
||||||
</>
|
</Button>
|
||||||
) : step === "poa" ? (
|
|
||||||
"Complete Registration"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</form> */}
|
</form>
|
||||||
</AuthLayout>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
@@ -13,6 +13,7 @@ import { api } from "@/services/api";
|
|||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthLayout from "@/components/auth/AuthLayout";
|
||||||
import CompanyProfileForm from "./CompanyProfileForm";
|
import CompanyProfileForm from "./CompanyProfileForm";
|
||||||
|
import ForwarderForm from "./ForwarderForm";
|
||||||
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
||||||
import TransporterForm from "./TransporterForm";
|
import TransporterForm from "./TransporterForm";
|
||||||
import type { OnboardingUserType } from "./types";
|
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() {
|
export default function OnboardingPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||||
|
|
||||||
useQuery(
|
|
||||||
api.fileUploadSettings.getByEntity.queryOptions({
|
|
||||||
input: { entity: "customer" },
|
|
||||||
refetchOnMount: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
||||||
importer: "customer",
|
importer: "customer",
|
||||||
exporter: "customer",
|
exporter: "customer",
|
||||||
@@ -237,6 +239,7 @@ export default function OnboardingPage() {
|
|||||||
<AuthLayout left={leftProps}>
|
<AuthLayout left={leftProps}>
|
||||||
{userType === "transporter" ? (
|
{userType === "transporter" ? (
|
||||||
<TransporterForm
|
<TransporterForm
|
||||||
|
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
@@ -244,6 +247,15 @@ export default function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : userType === "freight-forwarder-dj" ? (
|
) : userType === "freight-forwarder-dj" ? (
|
||||||
<DjiboutiAgentForm
|
<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}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
@@ -251,7 +263,7 @@ export default function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CompanyProfileForm
|
<CompanyProfileForm
|
||||||
userType={userType}
|
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Truck,
|
Truck,
|
||||||
Info,
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
@@ -20,8 +25,10 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
const TRUCK_TYPES = [
|
const TRUCK_TYPES = [
|
||||||
"Casoni",
|
"Casoni",
|
||||||
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
|
|||||||
"Others",
|
"Others",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type TransporterStep = "vehicle" | "documents" | "confirm";
|
||||||
|
|
||||||
const transporterSchema = z
|
const transporterSchema = z
|
||||||
.object({
|
.object({
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
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)"),
|
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["plateNumber2"],
|
path: ["plateNumber2"],
|
||||||
@@ -77,19 +89,34 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TransporterForm({
|
export default function TransporterForm({
|
||||||
|
documentSettingCode,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
|
documentSettingCode: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
watch,
|
watch,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
@@ -108,187 +135,341 @@ export default function TransporterForm({
|
|||||||
|
|
||||||
const truckType = watch("truckType");
|
const truckType = watch("truckType");
|
||||||
const isCasoni = truckType === "Casoni";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
Change account type
|
Change account type
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-center relative px-2">
|
<div className="flex items-center justify-between max-w-lg mx-auto 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">
|
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||||
<Truck className="size-5" />
|
<StepIcon
|
||||||
</div>
|
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>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{/* Personal Info (read-only) */}
|
{step === "vehicle" && (
|
||||||
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground">
|
<>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Info className="size-4" />
|
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||||
<span className="font-medium text-foreground">Account Holder</span>
|
<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>
|
</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">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ChevronLeft />
|
<ArrowLeft />
|
||||||
Change Type
|
{step === "vehicle"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
{step === "documents" && (
|
||||||
<>
|
<Button
|
||||||
<Loader2 className="animate-spin" />
|
type="button"
|
||||||
Submitting...
|
variant="outline"
|
||||||
</>
|
onClick={skipDocuments}
|
||||||
) : (
|
disabled={isPending}
|
||||||
"Complete Registration"
|
>
|
||||||
|
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>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user