style: auth pages fix

This commit is contained in:
Nathnael
2026-06-11 08:27:14 +00:00
parent 16f506062a
commit e01a7faa20
11 changed files with 1371 additions and 2296 deletions

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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 (

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);