mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
style: auth pages fix
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { ShieldCheck, Train } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface AuthLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -100,9 +100,9 @@ export default function AuthLayout({
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<Box className="w-full max-w-lg">
|
||||
<Box className="w-full max-w-xl">
|
||||
{/* Mobile brand */}
|
||||
<Group gap="sm" mb="xl" className="lg:hidden">
|
||||
<Group gap="sm" mb="xl" className="lg:hidden!">
|
||||
<ThemeIcon
|
||||
size={48}
|
||||
radius="lg"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common";
|
||||
import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core";
|
||||
|
||||
type InputPassthrough = Partial<TextInputProps>;
|
||||
|
||||
interface PhoneInputProps {
|
||||
disabled?: boolean;
|
||||
countryCode?: React.ComponentProps<typeof Input>;
|
||||
phone?: React.ComponentProps<typeof Input>;
|
||||
countryCode?: InputPassthrough;
|
||||
phone?: InputPassthrough;
|
||||
countryCodeError?: { message?: string };
|
||||
phoneError?: { message?: string };
|
||||
label?: string;
|
||||
@@ -17,27 +19,29 @@ export default function PhoneInput({
|
||||
phoneError,
|
||||
label = "Phone Number",
|
||||
}: PhoneInputProps) {
|
||||
const errorMsg = countryCodeError?.message ?? phoneError?.message;
|
||||
return (
|
||||
<Field data-invalid={Boolean(countryCodeError || phoneError)}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">{label}</Text>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<TextInput
|
||||
w={80}
|
||||
disabled={disabled}
|
||||
className="w-20"
|
||||
aria-invalid={Boolean(countryCodeError)}
|
||||
error={Boolean(countryCodeError)}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
{...countryCodeProps}
|
||||
/>
|
||||
<Input
|
||||
type="tel"
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder="912345678"
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
aria-invalid={Boolean(phoneError)}
|
||||
error={Boolean(phoneError)}
|
||||
{...phoneProps}
|
||||
/>
|
||||
</div>
|
||||
<FieldError errors={[countryCodeError, phoneError]} />
|
||||
</Field>
|
||||
</Group>
|
||||
{errorMsg && (
|
||||
<Text size="xs" c="red.6">{errorMsg}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileText,
|
||||
Loader2,
|
||||
UploadCloud,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
|
||||
@@ -38,10 +32,7 @@ const onboardingSchema = z.object({
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||
@@ -61,26 +52,8 @@ const onboardingSchema = z.object({
|
||||
type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
],
|
||||
personnel: [
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"contactPersonPhoneCountryCode",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
"generalManagerPhoneCountryCode",
|
||||
],
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
||||
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
|
||||
poa: [],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
@@ -103,10 +76,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone:
|
||||
data.poaPhone && data.poaPhoneCountryCode
|
||||
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||
: undefined,
|
||||
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
@@ -125,59 +95,29 @@ export default function CompanyProfileForm({
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
onDocumentFilesChange?: (
|
||||
files: Record<string, File | File[] | null>,
|
||||
) => void;
|
||||
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCompanyPayload) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>("company");
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const documentFiles = controlledFiles ?? internalFiles;
|
||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||
|
||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: documentSettingCode },
|
||||
refetchOnMount: false,
|
||||
}),
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+251",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPhone: "",
|
||||
contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaPhoneCountryCode: "+251",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
poaLocation: "",
|
||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
|
||||
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
|
||||
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,468 +126,299 @@ export default function CompanyProfileForm({
|
||||
const totalSteps = 5;
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "poa") {
|
||||
setStep("documents");
|
||||
return;
|
||||
}
|
||||
if (step === "documents") {
|
||||
setStep("confirm");
|
||||
return;
|
||||
}
|
||||
if (step === "confirm") {
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
const fields = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (step === "poa") { setStep("documents"); return; }
|
||||
if (step === "documents") { setStep("confirm"); return; }
|
||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
||||
const isValid = await trigger(stepFields[step]);
|
||||
if (!isValid) return;
|
||||
setStep(step === "company" ? "personnel" : "poa");
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "company") {
|
||||
onBack();
|
||||
} else if (step === "personnel") {
|
||||
setStep("company");
|
||||
} else if (step === "poa") {
|
||||
setStep("personnel");
|
||||
} else if (step === "documents") {
|
||||
setStep("poa");
|
||||
} else {
|
||||
setStep("documents");
|
||||
}
|
||||
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");
|
||||
};
|
||||
|
||||
const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [
|
||||
{ key: "company", icon: <Building2 size={18} /> },
|
||||
{ key: "personnel", icon: <User size={18} /> },
|
||||
{ key: "poa", icon: <FileText size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<CompanyStep, string> = {
|
||||
company: `Step 1 of ${totalSteps} — Company Information`,
|
||||
personnel: `Step 2 of ${totalSteps} — Personnel Details`,
|
||||
poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`,
|
||||
documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`,
|
||||
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
||||
};
|
||||
|
||||
const stepOrder: CompanyStep[] = ["company", "personnel", "poa", "documents", "confirm"];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
<Box mb="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
c="edr-muted"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step !== "company"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<User className="size-5" />}
|
||||
active={step === "personnel"}
|
||||
completed={
|
||||
step === "poa" || 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 ${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>
|
||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||
{STEPS.map(({ key, icon }, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return done || active ? (
|
||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
||||
{done ? <CheckCircle2 size={18} /> : icon}
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Box
|
||||
key={key}
|
||||
w={40}
|
||||
h={40}
|
||||
c="edr-slate"
|
||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
||||
{STEP_LABELS[step]}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Global Logistics Ltd"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location</FieldLabel>
|
||||
<Input
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.vatNumber)}>
|
||||
<FieldLabel>VAT Number</FieldLabel>
|
||||
<Input
|
||||
placeholder="VAT-12345"
|
||||
aria-invalid={Boolean(errors.vatNumber)}
|
||||
maxLength={10}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.vatNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
{...register("fanNumber")}
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Contact Person
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.contactPersonName)}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Jane Smith"
|
||||
aria-invalid={Boolean(errors.contactPersonName)}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<FieldError errors={[errors.contactPersonName]} />
|
||||
</Field>
|
||||
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
||||
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("contactPersonPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("contactPersonPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="edr-border" />
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
General Manager
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
className="col-span-2"
|
||||
data-invalid={Boolean(errors.generalManagerName)}
|
||||
>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Abebe Bikila"
|
||||
aria-invalid={Boolean(errors.generalManagerName)}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
aria-invalid={Boolean(errors.generalManagerEmail)}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("generalManagerPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("generalManagerPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
|
||||
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Power of Attorney details are optional. Fill them in if you have
|
||||
them, or skip to continue.
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(errors.poaName)}>
|
||||
<FieldLabel>PoA Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Authorized Representative Name"
|
||||
aria-invalid={Boolean(errors.poaName)}
|
||||
{...register("poaName")}
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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}
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 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>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "documents" && (
|
||||
<>
|
||||
{loadingDocuments ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !uploadSetting ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
No document requirements found for your account type.
|
||||
</p>
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
</div>
|
||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<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>
|
||||
<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} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "company"
|
||||
? "Change Type"
|
||||
: step === "confirm"
|
||||
? "Back to Documents"
|
||||
: "Back"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending}
|
||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : step === "documents" ? (
|
||||
"Continue"
|
||||
) : step === "confirm" ? (
|
||||
"Submit Registration"
|
||||
) : (
|
||||
<>
|
||||
Next Step
|
||||
<ArrowRight />
|
||||
</>
|
||||
)}
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
@@ -655,36 +426,13 @@ export default function CompanyProfileForm({
|
||||
|
||||
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">
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{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>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Box, Button, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
UserRound,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
UploadCloud,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
|
||||
@@ -45,20 +38,8 @@ const djiboutiSchema = z.object({
|
||||
type FormData = z.infer<typeof djiboutiSchema>;
|
||||
|
||||
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
],
|
||||
representative: [
|
||||
"repName",
|
||||
"repEmail",
|
||||
"repPhone",
|
||||
"repPhoneCountryCode",
|
||||
],
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"],
|
||||
representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
};
|
||||
@@ -92,47 +73,26 @@ export default function DjiboutiAgentForm({
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
onDocumentFilesChange?: (
|
||||
files: Record<string, File | File[] | null>,
|
||||
) => void;
|
||||
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCompanyPayload) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<DjiboutiStep>("company");
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const documentFiles = controlledFiles ?? internalFiles;
|
||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||
|
||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: documentSettingCode },
|
||||
refetchOnMount: false,
|
||||
}),
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(djiboutiSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+253",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
repName: "",
|
||||
repEmail: "",
|
||||
repPhone: "",
|
||||
repPhoneCountryCode: "+253",
|
||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253",
|
||||
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -141,224 +101,178 @@ export default function DjiboutiAgentForm({
|
||||
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 = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (step === "representative") { setStep("documents"); return; }
|
||||
if (step === "documents") { setStep("confirm"); return; }
|
||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
||||
const isValid = await trigger(stepFields[step]);
|
||||
if (!isValid) return;
|
||||
setStep("representative");
|
||||
};
|
||||
|
||||
const skipDocuments = () => {
|
||||
setStep("confirm");
|
||||
};
|
||||
const skipDocuments = () => setStep("confirm");
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "company") {
|
||||
onBack();
|
||||
} else if (step === "representative") {
|
||||
setStep("company");
|
||||
} else if (step === "documents") {
|
||||
setStep("representative");
|
||||
} else {
|
||||
setStep("documents");
|
||||
}
|
||||
if (step === "company") onBack();
|
||||
else if (step === "representative") setStep("company");
|
||||
else if (step === "documents") setStep("representative");
|
||||
else setStep("documents");
|
||||
};
|
||||
|
||||
const STEPS: { key: DjiboutiStep; icon: React.ReactNode }[] = [
|
||||
{ key: "company", icon: <Building2 size={18} /> },
|
||||
{ key: "representative", icon: <UserRound size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<DjiboutiStep, string> = {
|
||||
company: `Step 1 of ${totalSteps} — Company Information`,
|
||||
representative: `Step 2 of ${totalSteps} — Representative Details`,
|
||||
documents: `Step 3 of ${totalSteps} — Upload Documents (Optional)`,
|
||||
confirm: `Step 4 of ${totalSteps} — Review & Confirm`,
|
||||
};
|
||||
|
||||
const stepOrder: DjiboutiStep[] = ["company", "representative", "documents", "confirm"];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
<Box mb="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
c="edr-muted"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step !== "company"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<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">
|
||||
{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>
|
||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||
{STEPS.map(({ key, icon }, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return done || active ? (
|
||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
||||
{done ? <CheckCircle2 size={18} /> : icon}
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Box
|
||||
key={key}
|
||||
w={40}
|
||||
h={40}
|
||||
c="edr-slate"
|
||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
||||
{STEP_LABELS[step]}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Djibouti Logistics SARL"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Djibouti Logistics SARL"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="info@djib-logistics.dj"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="info@djib-logistics.dj"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "12345678",
|
||||
}}
|
||||
phone={{ ...register("companyPhone"), placeholder: "12345678" }}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location / Country</FieldLabel>
|
||||
<Input
|
||||
placeholder="Djibouti City, Djibouti"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Boulevard de la République"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Location / Country"
|
||||
placeholder="Djibouti City, Djibouti"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Boulevard de la République"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "representative" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Provide the company representative details for this account.
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(errors.repName)}>
|
||||
<FieldLabel>Representative Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Ahmed Hassan"
|
||||
aria-invalid={Boolean(errors.repName)}
|
||||
{...register("repName")}
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Representative Name"
|
||||
placeholder="Ahmed Hassan"
|
||||
error={errors.repName?.message}
|
||||
{...register("repName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Representative Email"
|
||||
type="email"
|
||||
placeholder="ahmed@company.dj"
|
||||
error={errors.repEmail?.message}
|
||||
{...register("repEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.repName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.repEmail)}>
|
||||
<FieldLabel>Representative Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ahmed@company.dj"
|
||||
aria-invalid={Boolean(errors.repEmail)}
|
||||
{...register("repEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.repEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("repPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("repPhone"),
|
||||
placeholder: "12345678",
|
||||
}}
|
||||
phone={{ ...register("repPhone"), placeholder: "12345678" }}
|
||||
countryCodeError={errors.repPhoneCountryCode}
|
||||
phoneError={errors.repPhone}
|
||||
label="Representative Phone"
|
||||
/>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "documents" && (
|
||||
<>
|
||||
{loadingDocuments ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !uploadSetting ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
No document requirements found for your account type.
|
||||
</p>
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
</div>
|
||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
||||
@@ -366,60 +280,33 @@ export default function DjiboutiAgentForm({
|
||||
<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>
|
||||
<ReviewRow label="Rep. phone" value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "company"
|
||||
? "Change Type"
|
||||
: step === "confirm"
|
||||
? "Back to Documents"
|
||||
: "Back"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{step === "documents" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={skipDocuments}
|
||||
disabled={isPending}
|
||||
>
|
||||
Skip for now
|
||||
</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 />
|
||||
</>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
{step === "documents" && (
|
||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending}
|
||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
@@ -427,37 +314,13 @@ export default function DjiboutiAgentForm({
|
||||
|
||||
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">
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{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>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileText,
|
||||
UploadCloud,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
|
||||
@@ -38,10 +31,7 @@ const forwarderSchema = z.object({
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||
@@ -61,26 +51,8 @@ const forwarderSchema = z.object({
|
||||
type FormData = z.infer<typeof forwarderSchema>;
|
||||
|
||||
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyPhoneCountryCode",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
],
|
||||
personnel: [
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"contactPersonPhoneCountryCode",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
"generalManagerPhoneCountryCode",
|
||||
],
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
||||
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
|
||||
poa: [],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
@@ -103,10 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone:
|
||||
data.poaPhone && data.poaPhoneCountryCode
|
||||
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||
: undefined,
|
||||
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
@@ -125,59 +94,29 @@ export default function ForwarderForm({
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
onDocumentFilesChange?: (
|
||||
files: Record<string, File | File[] | null>,
|
||||
) => void;
|
||||
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCompanyPayload) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<ForwarderStep>("company");
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const documentFiles = controlledFiles ?? internalFiles;
|
||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||
|
||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: documentSettingCode },
|
||||
refetchOnMount: false,
|
||||
}),
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(forwarderSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+251",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPhone: "",
|
||||
contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaPhoneCountryCode: "+251",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
poaLocation: "",
|
||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
|
||||
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
|
||||
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,371 +125,265 @@ export default function ForwarderForm({
|
||||
const totalSteps = 5;
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "poa") {
|
||||
setStep("documents");
|
||||
return;
|
||||
}
|
||||
if (step === "documents") {
|
||||
setStep("confirm");
|
||||
return;
|
||||
}
|
||||
if (step === "confirm") {
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
const fields = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (step === "poa") { setStep("documents"); return; }
|
||||
if (step === "documents") { setStep("confirm"); return; }
|
||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
||||
const isValid = await trigger(stepFields[step]);
|
||||
if (!isValid) return;
|
||||
setStep(step === "company" ? "personnel" : "poa");
|
||||
};
|
||||
|
||||
const skipDocuments = () => {
|
||||
setStep("confirm");
|
||||
};
|
||||
const skipDocuments = () => setStep("confirm");
|
||||
|
||||
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");
|
||||
}
|
||||
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");
|
||||
};
|
||||
|
||||
const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [
|
||||
{ key: "company", icon: <Building2 size={18} /> },
|
||||
{ key: "personnel", icon: <User size={18} /> },
|
||||
{ key: "poa", icon: <FileText size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<ForwarderStep, string> = {
|
||||
company: `Step 1 of ${totalSteps} — Company Information`,
|
||||
personnel: `Step 2 of ${totalSteps} — Personnel Details`,
|
||||
poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`,
|
||||
documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`,
|
||||
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
||||
};
|
||||
|
||||
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
<Box mb="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
c="edr-muted"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<Building2 className="size-5" />}
|
||||
active={step === "company"}
|
||||
completed={step !== "company"}
|
||||
/>
|
||||
<StepIcon
|
||||
icon={<User className="size-5" />}
|
||||
active={step === "personnel"}
|
||||
completed={step === "poa" || 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 ${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>
|
||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||
{STEPS.map(({ key, icon }, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return done || active ? (
|
||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
||||
{done ? <CheckCircle2 size={18} /> : icon}
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Box
|
||||
key={key}
|
||||
w={40}
|
||||
h={40}
|
||||
c="edr-slate"
|
||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
||||
{STEP_LABELS[step]}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.companyName)}>
|
||||
<FieldLabel>Company Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Global Logistics Ltd"
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
{...register("companyName")}
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyEmail)}>
|
||||
<FieldLabel>Company Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
aria-invalid={Boolean(errors.companyEmail)}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.companyLocation)}>
|
||||
<FieldLabel>Location</FieldLabel>
|
||||
<Input
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
aria-invalid={Boolean(errors.companyLocation)}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyLocation]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.companyAddress)}>
|
||||
<FieldLabel>Address</FieldLabel>
|
||||
<Input
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
aria-invalid={Boolean(errors.companyAddress)}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
<FieldError errors={[errors.companyAddress]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.vatNumber)}>
|
||||
<FieldLabel>VAT Number</FieldLabel>
|
||||
<Input
|
||||
placeholder="VAT-12345"
|
||||
aria-invalid={Boolean(errors.vatNumber)}
|
||||
maxLength={10}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.vatNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
{...register("fanNumber")}
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Contact Person
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.contactPersonName)}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Jane Smith"
|
||||
aria-invalid={Boolean(errors.contactPersonName)}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<FieldError errors={[errors.contactPersonName]} />
|
||||
</Field>
|
||||
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
||||
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("contactPersonPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("contactPersonPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="edr-border" />
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
General Manager
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
className="col-span-2"
|
||||
data-invalid={Boolean(errors.generalManagerName)}
|
||||
>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Abebe Bikila"
|
||||
aria-invalid={Boolean(errors.generalManagerName)}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
aria-invalid={Boolean(errors.generalManagerEmail)}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.generalManagerEmail]} />
|
||||
</Field>
|
||||
|
||||
<PhoneInput
|
||||
countryCode={{
|
||||
...register("generalManagerPhoneCountryCode"),
|
||||
}}
|
||||
phone={{
|
||||
...register("generalManagerPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
|
||||
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Power of Attorney details are optional. Fill them in if you have
|
||||
them, or skip to continue.
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(errors.poaName)}>
|
||||
<FieldLabel>PoA Name</FieldLabel>
|
||||
<Input
|
||||
placeholder="Authorized Representative Name"
|
||||
aria-invalid={Boolean(errors.poaName)}
|
||||
{...register("poaName")}
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<FieldError errors={[errors.poaName]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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}
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 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>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "documents" && (
|
||||
<>
|
||||
{loadingDocuments ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !uploadSetting ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
No document requirements found for your account type.
|
||||
</p>
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
</div>
|
||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
||||
@@ -559,96 +392,41 @@ export default function ForwarderForm({
|
||||
<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>
|
||||
<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} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "company"
|
||||
? "Change Type"
|
||||
: step === "confirm"
|
||||
? "Back to Documents"
|
||||
: "Back"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{step === "documents" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={skipDocuments}
|
||||
disabled={isPending}
|
||||
>
|
||||
Skip for now
|
||||
</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 />
|
||||
</>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
{step === "documents" && (
|
||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending}
|
||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
@@ -656,36 +434,13 @@ export default function ForwarderForm({
|
||||
|
||||
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">
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{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>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
|
||||
type LoginMethod = "email" | "phone";
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const [method, setMethod] = useState<LoginMethod>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
@@ -31,12 +26,15 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const loginId = method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
||||
const loginId =
|
||||
method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
||||
const result = await login({ email: loginId, password });
|
||||
if (result.success) {
|
||||
navigate("/");
|
||||
const from = (location.state as { from?: { pathname: string } } | null)
|
||||
?.from?.pathname;
|
||||
navigate(from ?? "/portal", { replace: true });
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
@@ -60,130 +58,141 @@ export default function LoginPage() {
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Corridors",
|
||||
value: "24+",
|
||||
footer: "Operational",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Mail className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black tracking-tight">Welcome back</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Enter your credentials to access your portal
|
||||
</p>
|
||||
</div>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box
|
||||
w={48}
|
||||
h={48}
|
||||
bg="edr-soft"
|
||||
className="flex items-center justify-center rounded-2xl"
|
||||
>
|
||||
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Welcome back
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Enter your credentials to access your portal
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex gap-2 rounded-lg bg-muted p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={"ghost"}
|
||||
size="sm"
|
||||
onClick={() => setMethod("email")}
|
||||
className={`flex-1 hover:bg-background/40! ${method === "email" ? "bg-background shadow border" : ""}`}
|
||||
>
|
||||
<Mail data-icon="inline-start" />
|
||||
Email
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"ghost"}
|
||||
size="sm"
|
||||
onClick={() => setMethod("phone")}
|
||||
className={`flex-1 hover:bg-background/40! ${method === "phone" ? "bg-background shadow border" : ""}`}
|
||||
>
|
||||
<Phone data-icon="inline-start" />
|
||||
Phone
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v as LoginMethod)}
|
||||
fullWidth
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Mail size={15} />
|
||||
<Text size="sm">Email</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "email",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Phone size={15} />
|
||||
<Text size="sm">Phone</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "phone",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<FieldGroup>
|
||||
{method === "email" ? (
|
||||
<Field>
|
||||
<FieldLabel>Email Address</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</Field>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="name@company.com"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{
|
||||
value: countryCode,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCountryCode(e.target.value),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCountryCode(e.target.value),
|
||||
}}
|
||||
phone={{
|
||||
value: phoneNumber,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPhoneNumber(e.target.value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Password
|
||||
</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
variant="transparent"
|
||||
size="xs"
|
||||
className="h-auto p-0"
|
||||
c="edr-green.6"
|
||||
p={0}
|
||||
h="auto"
|
||||
fz={12}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
</Group>
|
||||
<PasswordInput
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} size="lg" className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</>
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="h-auto p-0 font-semibold"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Create an account
|
||||
Sign In
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
onClick={() => navigate("/signup")}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Ship,
|
||||
Truck,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import CompanyProfileForm from "./CompanyProfileForm";
|
||||
import ForwarderForm from "./ForwarderForm";
|
||||
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
||||
import ForwarderForm from "./ForwarderForm";
|
||||
import TransporterForm from "./TransporterForm";
|
||||
import type { OnboardingUserType } from "./types";
|
||||
|
||||
@@ -25,45 +27,41 @@ const USER_TYPE_CARDS: {
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: "importer",
|
||||
label: "Importer",
|
||||
description: "Import goods into Ethiopia via the railway corridor.",
|
||||
icon: <ArrowDownToLine className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "exporter",
|
||||
label: "Exporter",
|
||||
description: "Export goods from Ethiopia via rail.",
|
||||
icon: <ArrowUpFromLine className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-et",
|
||||
label: "Freight Forwarder (Ethiopia)",
|
||||
description: "Ethiopian freight forwarding company handling client cargo.",
|
||||
icon: <Building2 className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-dj",
|
||||
label: "FF Agent (Djibouti)",
|
||||
description: "Djibouti-based agent coordinating cross-border logistics.",
|
||||
icon: <Ship className="size-6" />,
|
||||
},
|
||||
{
|
||||
id: "transporter",
|
||||
label: "Transporter",
|
||||
description: "Trucking company providing first/last-mile services.",
|
||||
icon: <Truck className="size-6" />,
|
||||
},
|
||||
];
|
||||
{
|
||||
id: "importer",
|
||||
label: "Importer",
|
||||
description: "Import goods into Ethiopia via the railway corridor.",
|
||||
icon: <ArrowDownToLine size={22} />,
|
||||
},
|
||||
{
|
||||
id: "exporter",
|
||||
label: "Exporter",
|
||||
description: "Export goods from Ethiopia via rail.",
|
||||
icon: <ArrowUpFromLine size={22} />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-et",
|
||||
label: "Freight Forwarder (Ethiopia)",
|
||||
description: "Ethiopian freight forwarding company handling client cargo.",
|
||||
icon: <Building2 size={22} />,
|
||||
},
|
||||
{
|
||||
id: "freight-forwarder-dj",
|
||||
label: "FF Agent (Djibouti)",
|
||||
description: "Djibouti-based agent coordinating cross-border logistics.",
|
||||
icon: <Ship size={22} />,
|
||||
},
|
||||
{
|
||||
id: "transporter",
|
||||
label: "Transporter",
|
||||
description: "Trucking company providing first/last-mile services.",
|
||||
icon: <Truck size={22} />,
|
||||
},
|
||||
];
|
||||
|
||||
const USER_TYPE_LEFT_MAP: Record<
|
||||
OnboardingUserType,
|
||||
{
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
{ badge: string; title: string; description: string }
|
||||
> = {
|
||||
importer: {
|
||||
badge: "Importer Registration",
|
||||
@@ -107,12 +105,7 @@ const PREFLIGHT_LEFT = {
|
||||
"Freight Forwarders (Ethiopia & Djibouti)",
|
||||
"Transporters & Fleet Operators",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Customers",
|
||||
value: "500+",
|
||||
footer: "And growing",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
|
||||
};
|
||||
|
||||
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
|
||||
@@ -127,9 +120,7 @@ export default function OnboardingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
|
||||
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
||||
importer: "customer",
|
||||
@@ -140,8 +131,7 @@ export default function OnboardingPage() {
|
||||
};
|
||||
|
||||
const createCompanyMutation = useMutation({
|
||||
mutationFn: (payload: CreateCompanyPayload) =>
|
||||
api.companies.create.call(payload),
|
||||
mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload),
|
||||
onSuccess: async (data) => {
|
||||
const hasFiles = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
@@ -149,100 +139,81 @@ export default function OnboardingPage() {
|
||||
if (hasFiles) {
|
||||
await companiesService.uploadDocuments(data.company.id, documentFiles);
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() });
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleSubmit = (payload: CreateCompanyPayload) => {
|
||||
const enriched: CreateCompanyPayload = {
|
||||
...payload,
|
||||
companyType: COMPANY_TYPE_MAP[userType!],
|
||||
};
|
||||
const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] };
|
||||
createCompanyMutation.mutate(enriched);
|
||||
};
|
||||
|
||||
const handleSelectType = (type: OnboardingUserType) => {
|
||||
setUserType(type);
|
||||
};
|
||||
const handleSelectType = (type: OnboardingUserType) => setUserType(type);
|
||||
const handleBack = () => setUserType(null);
|
||||
|
||||
const handleBack = () => {
|
||||
setUserType(null);
|
||||
};
|
||||
|
||||
// Preflight: user type selection
|
||||
if (!userType) {
|
||||
return (
|
||||
<AuthLayout left={PREFLIGHT_LEFT}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
<Stack gap="lg">
|
||||
<Box>
|
||||
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
|
||||
Select Account Type
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Choose the account type that fits your role.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{USER_TYPE_CARDS.map((card) => (
|
||||
<button
|
||||
<UnstyledButton
|
||||
key={card.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectType(card.id)}
|
||||
className="group relative flex flex-col items-start gap-3 rounded-xl border-2 border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-primary/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group block rounded-lg shadow-lg! border! border-edr-border! bg-edr-card! p-5! text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft hover:shadow-[0_12px_28px_-14px_rgba(14,163,83,0.55)]"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
{card.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{card.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full border-2 border-border text-transparent transition-all group-hover:border-primary group-hover:text-primary">
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
className="shrink-0 transition-colors group-hover:!bg-[var(--mantine-color-edr-green-6)] group-hover:!text-white"
|
||||
>
|
||||
{card.icon}
|
||||
</ThemeIcon>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{card.label}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{card.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="shrink-0 text-[var(--mantine-color-edr-muted-6)] transition-all group-hover:translate-x-0.5 group-hover:text-[var(--mantine-color-edr-green-6)]"
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the appropriate form based on user type
|
||||
const leftConfig = USER_TYPE_LEFT_MAP[userType];
|
||||
const leftProps = {
|
||||
...leftConfig,
|
||||
features:
|
||||
userType === "transporter"
|
||||
? [
|
||||
"Vehicle & fleet registration",
|
||||
"TIN & FAN verification",
|
||||
"First-mile / Last-mile eligibility",
|
||||
]
|
||||
? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"]
|
||||
: userType === "freight-forwarder-dj"
|
||||
? [
|
||||
"Company details",
|
||||
"Representative information",
|
||||
"Cross-border operations",
|
||||
]
|
||||
: [
|
||||
"Company registration details",
|
||||
"Contact and management personnel",
|
||||
"Power of Attorney (optional)",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Customers",
|
||||
value: "500+",
|
||||
footer: "And growing",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
? ["Company details", "Representative information", "Cross-border operations"]
|
||||
: ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"],
|
||||
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { ArrowRight, Check, Eye, EyeOff, LockKeyhole, Loader2, X } from "lucide-react";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
@@ -45,8 +38,6 @@ type FormData = z.infer<typeof passwordSchema>;
|
||||
export default function SetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setPassword } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -105,112 +96,77 @@ export default function SetPasswordPage() {
|
||||
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<LockKeyhole className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black tracking-tight">Set Password</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Create a secure password for your account.
|
||||
</p>
|
||||
</div>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Set Password
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Create a secure password for your account.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.password)}>
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
className="pr-12"
|
||||
{...register("password")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[errors.password]} />
|
||||
</Field>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{password && (
|
||||
<Stack gap={4} mt={8}>
|
||||
{requirements.map((req) => (
|
||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={16}
|
||||
radius="xl"
|
||||
variant={req.met ? "filled" : "light"}
|
||||
color={req.met ? "edr-green" : "gray"}
|
||||
>
|
||||
{req.met ? <Check size={10} /> : <X size={10} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c={req.met ? "edr-green.7" : "edr-muted"}>
|
||||
{req.label}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{password && (
|
||||
<ul className="space-y-1.5">
|
||||
{requirements.map((req) => (
|
||||
<li
|
||||
key={req.label}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
req.met ? "text-emerald-600" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{req.met ? (
|
||||
<Check className="size-4 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<X className="size-4 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Confirm password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Field data-invalid={Boolean(errors.confirmPassword)}>
|
||||
<FieldLabel>Confirm Password</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.confirmPassword)}
|
||||
className="pr-12"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[errors.confirmPassword]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !allMet}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Save Password
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !allMet}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Save Password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
|
||||
@@ -1,60 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
UserPlus,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
countryCode: z.string().min(1, "Country code is required"),
|
||||
phone: z
|
||||
.string()
|
||||
.min(9, "Phone number is too short")
|
||||
.max(9, "Phone number is too long"),
|
||||
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
lastName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
@@ -76,8 +49,6 @@ export default function SignupPage() {
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -102,9 +73,7 @@ export default function SignupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const normalizedPhone = data.phone.startsWith("0")
|
||||
? data.phone.slice(1)
|
||||
: data.phone;
|
||||
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
@@ -120,7 +89,6 @@ export default function SignupPage() {
|
||||
const result = await signup(payload);
|
||||
if (result.success) {
|
||||
navigate("/portal");
|
||||
// navigate("/otp");
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
@@ -131,6 +99,8 @@ export default function SignupPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
@@ -144,68 +114,56 @@ export default function SignupPage() {
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: {
|
||||
label: "Active Corridors",
|
||||
value: "24+",
|
||||
footer: "Operational",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<UserPlus className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black tracking-tight">Create Account</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Create Account
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Register to access EDR Freight services.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FieldGroup className="gap-4">
|
||||
<div className="flex gap-4">
|
||||
<Field data-invalid={Boolean(errors.firstName?.en)}>
|
||||
<FieldLabel>First Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.firstName?.en)}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.firstName?.en]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.lastName?.en)}>
|
||||
<FieldLabel>Last Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.lastName?.en)}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.lastName?.en]} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errors.email)}>
|
||||
<FieldLabel>Email Address</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
{...register("email")}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
<FieldError errors={[errors.email]} />
|
||||
</Field>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
disabled={loading}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
@@ -215,108 +173,73 @@ export default function SignupPage() {
|
||||
phoneError={errors.phone}
|
||||
/>
|
||||
|
||||
<Field data-invalid={Boolean(errors.password)}>
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
className="pr-10"
|
||||
{...register("password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError errors={[errors.password]} />
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(watch("password") ?? "");
|
||||
return (
|
||||
<div
|
||||
key={req.label}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-emerald-600" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="size-3" />
|
||||
) : (
|
||||
<X className="size-3" />
|
||||
)}
|
||||
{req.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
<Box>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 && (
|
||||
<Stack gap={4} mt={8}>
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={16}
|
||||
radius="xl"
|
||||
variant={met ? "filled" : "light"}
|
||||
color={met ? "edr-green" : "gray"}
|
||||
>
|
||||
{met ? <Check size={10} /> : <X size={10} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
||||
{req.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Field data-invalid={Boolean(errors.confirmPassword)}>
|
||||
<FieldLabel>Confirm Password</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.confirmPassword)}
|
||||
className="pr-10"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError errors={[errors.confirmPassword]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} size="lg" className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create Account
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigate("/login")}
|
||||
className="h-auto p-0 font-semibold"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Sign In
|
||||
Create Account
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Box, Button, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
Truck,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
const TRUCK_TYPES = [
|
||||
"Casoni",
|
||||
"Truck Trailer",
|
||||
"High Bed",
|
||||
"Low Bed",
|
||||
"Others",
|
||||
] as const;
|
||||
const TRUCK_TYPES = ["Casoni", "Truck Trailer", "High Bed", "Low Bed", "Others"] as const;
|
||||
|
||||
type TransporterStep = "vehicle" | "documents" | "confirm";
|
||||
|
||||
@@ -54,10 +36,7 @@ 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"],
|
||||
@@ -99,45 +78,25 @@ export default function TransporterForm({
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
onDocumentFilesChange?: (
|
||||
files: Record<string, File | File[] | null>,
|
||||
) => void;
|
||||
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
|
||||
user: AuthUser;
|
||||
onSubmit: (data: CreateCompanyPayload) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<TransporterStep>("vehicle");
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const documentFiles = controlledFiles ?? internalFiles;
|
||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||
|
||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: documentSettingCode },
|
||||
refetchOnMount: false,
|
||||
}),
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
const { register, handleSubmit, trigger, watch, control, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(transporterSchema),
|
||||
defaultValues: {
|
||||
tinNumber: "",
|
||||
fanNumber: "",
|
||||
truckType: "",
|
||||
plateNumber: "",
|
||||
plateNumber2: "",
|
||||
vehicleModel: "",
|
||||
yearOfManufacturing: "",
|
||||
tinNumber: "", fanNumber: "", truckType: "", plateNumber: "", plateNumber2: "", vehicleModel: "", yearOfManufacturing: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,298 +107,220 @@ export default function TransporterForm({
|
||||
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",
|
||||
];
|
||||
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 skipDocuments = () => setStep("confirm");
|
||||
|
||||
const prevStep = () => {
|
||||
if (step === "vehicle") {
|
||||
onBack();
|
||||
} else if (step === "documents") {
|
||||
setStep("vehicle");
|
||||
} else {
|
||||
setStep("documents");
|
||||
}
|
||||
if (step === "vehicle") onBack();
|
||||
else if (step === "documents") setStep("vehicle");
|
||||
else setStep("documents");
|
||||
};
|
||||
|
||||
const STEPS: { key: TransporterStep; icon: React.ReactNode }[] = [
|
||||
{ key: "vehicle", icon: <Truck size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<TransporterStep, string> = {
|
||||
vehicle: `Step 1 of ${totalSteps} — Vehicle Information`,
|
||||
documents: `Step 2 of ${totalSteps} — Upload Documents (Optional)`,
|
||||
confirm: `Step 3 of ${totalSteps} — Review & Confirm`,
|
||||
};
|
||||
|
||||
const stepOrder: TransporterStep[] = ["vehicle", "documents", "confirm"];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
<Box mb="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
c="edr-muted"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||
<StepIcon
|
||||
icon={<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">
|
||||
{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>
|
||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||
{STEPS.map(({ key, icon }, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return done || active ? (
|
||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
||||
{done ? <CheckCircle2 size={18} /> : icon}
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Box
|
||||
key={key}
|
||||
w={40}
|
||||
h={40}
|
||||
c="edr-slate"
|
||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{step === "vehicle" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||
<FieldLabel>TIN Number (10 digits)</FieldLabel>
|
||||
<Input
|
||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
||||
{STEP_LABELS[step]}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "vehicle" && (
|
||||
<>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
aria-invalid={Boolean(errors.tinNumber)}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.fanNumber)}>
|
||||
<FieldLabel>FAN Number (16 digits)</FieldLabel>
|
||||
<Input
|
||||
<TextInput
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
aria-invalid={Boolean(errors.fanNumber)}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.fanNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
|
||||
<hr className="border-border" />
|
||||
<Divider color="edr-border" />
|
||||
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Vehicle / Truck Information
|
||||
</h3>
|
||||
<Text fw={600} size="sm" c="edr-text">Vehicle / Truck Information</Text>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="truckType"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
label="Truck Type"
|
||||
placeholder="Select truck type..."
|
||||
data={TRUCK_TYPES as unknown as string[]}
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
error={fieldState.error?.message}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={`Plate Number${isCasoni ? " (Front)" : ""}`}
|
||||
placeholder="AA-12345"
|
||||
error={errors.plateNumber?.message}
|
||||
{...register("plateNumber")}
|
||||
/>
|
||||
<FieldError errors={[errors.plateNumber]} />
|
||||
</Field>
|
||||
|
||||
{isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.plateNumber2)}>
|
||||
<FieldLabel>Plate Number (Trailer)</FieldLabel>
|
||||
<Input
|
||||
{isCasoni ? (
|
||||
<TextInput
|
||||
label="Plate Number (Trailer)"
|
||||
placeholder="AA-67890"
|
||||
aria-invalid={Boolean(errors.plateNumber2)}
|
||||
error={errors.plateNumber2?.message}
|
||||
{...register("plateNumber2")}
|
||||
/>
|
||||
<FieldError errors={[errors.plateNumber2]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.vehicleModel)}>
|
||||
<FieldLabel>Vehicle Model</FieldLabel>
|
||||
<Input
|
||||
) : (
|
||||
<TextInput
|
||||
label="Vehicle Model"
|
||||
placeholder="Isuzu FVR 2024"
|
||||
aria-invalid={Boolean(errors.vehicleModel)}
|
||||
error={errors.vehicleModel?.message}
|
||||
{...register("vehicleModel")}
|
||||
/>
|
||||
<FieldError errors={[errors.vehicleModel]} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isCasoni && (
|
||||
<Field data-invalid={Boolean(errors.vehicleModel)}>
|
||||
<FieldLabel>Vehicle Model</FieldLabel>
|
||||
<Input
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{isCasoni && (
|
||||
<TextInput
|
||||
label="Vehicle Model"
|
||||
placeholder="Isuzu FVR 2024"
|
||||
aria-invalid={Boolean(errors.vehicleModel)}
|
||||
error={errors.vehicleModel?.message}
|
||||
{...register("vehicleModel")}
|
||||
/>
|
||||
<FieldError errors={[errors.vehicleModel]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
|
||||
<FieldLabel>Year of Manufacturing</FieldLabel>
|
||||
<Input
|
||||
)}
|
||||
<TextInput
|
||||
label="Year of Manufacturing"
|
||||
placeholder="2023"
|
||||
maxLength={4}
|
||||
aria-invalid={Boolean(errors.yearOfManufacturing)}
|
||||
error={errors.yearOfManufacturing?.message}
|
||||
{...register("yearOfManufacturing")}
|
||||
/>
|
||||
<FieldError errors={[errors.yearOfManufacturing]} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={prevStep}>
|
||||
<ArrowLeft />
|
||||
{step === "vehicle"
|
||||
? "Change Type"
|
||||
: step === "confirm"
|
||||
? "Back to Documents"
|
||||
: "Back"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{step === "documents" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={skipDocuments}
|
||||
disabled={isPending}
|
||||
>
|
||||
Skip for now
|
||||
</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"
|
||||
{step === "documents" && (
|
||||
<>
|
||||
{loadingDocuments ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !uploadSetting ? (
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
No document requirements found for your account type.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
Next Step
|
||||
<ArrowRight />
|
||||
</>
|
||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "confirm" && (
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<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} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "vehicle" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
{step === "documents" && (
|
||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending}
|
||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
@@ -447,37 +328,13 @@ export default function TransporterForm({
|
||||
|
||||
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">
|
||||
<Box p={12} className="rounded-xl border border-edr-border bg-edr-card">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{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>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, Box, Button, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, MailCheck, RotateCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react";
|
||||
|
||||
import { verificationCodeType } from "@/enums/verificationCodeType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const otpSchema = z.object({
|
||||
code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"),
|
||||
@@ -96,106 +90,101 @@ export default function VerificationOtpPage() {
|
||||
stats: { label: "Verification Security", value: "99.9%", footer: "Protected", progress: "w-[99%]" },
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<MailCheck className="size-6" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black tracking-tight">OTP Verification</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">Enter the 6-digit code sent to:</p>
|
||||
<div className="mt-3 rounded-xl border border-border bg-muted/50 px-4 py-2">
|
||||
<p className="text-sm font-semibold">{maskedPhone}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<MailCheck size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
OTP Verification
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Enter the 6-digit code sent to:
|
||||
</Text>
|
||||
<Box mt={8} p={12} bg="edr-slate-soft" className="rounded-xl border border-edr-border">
|
||||
<Text size="sm" fw={600} c="edr-text">{maskedPhone}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{resentMessage && (
|
||||
<div className="mb-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm text-blue-700">
|
||||
<Alert color="blue" variant="light" radius="md" mb="md">
|
||||
{resentMessage}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.code)}>
|
||||
<FieldLabel>Verification Code</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="123456"
|
||||
disabled={verifying}
|
||||
aria-invalid={Boolean(errors.code)}
|
||||
className="h-14 text-center text-2xl font-black tracking-[10px]"
|
||||
error={errors.code?.message}
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
fontSize: "24px",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "10px",
|
||||
height: "56px",
|
||||
},
|
||||
}}
|
||||
{...register("code")}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center justify-between">
|
||||
{errors.code ? (
|
||||
<FieldError errors={[errors.code]} />
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Enter the OTP sent to your phone</p>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{otpValue.length}/6</span>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Text size="xs" c="edr-muted" ta="right" mt={4}>
|
||||
{otpValue.length}/6
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifying || otpValue.length !== 6}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Verify Account
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifying || otpValue.length !== 6}
|
||||
loading={verifying}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!verifying ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Verify Account
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="w-full"
|
||||
>
|
||||
{resending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCw data-icon="inline-start" />
|
||||
Resend Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Didn't receive the code?
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleResend}
|
||||
className="h-auto p-0 font-semibold"
|
||||
disabled={resending}
|
||||
loading={resending}
|
||||
fullWidth
|
||||
leftSection={!resending ? <RotateCw size={16} /> : undefined}
|
||||
>
|
||||
Send again
|
||||
Resend Code
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Didn't receive the code?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
onClick={handleResend}
|
||||
>
|
||||
Send again
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user