feat(freight-portal): gate company step on etrade verification

This commit is contained in:
Nathnael
2026-07-29 12:03:45 +00:00
parent 1081b813bb
commit 0165a64e76
7 changed files with 478 additions and 290 deletions

View File

@@ -1,19 +1,19 @@
import {
Alert,
Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core";
import { useEffect, useRef } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
import { AlertCircle, Download } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
export type ETradeStatus =
| "idle"
| "loading"
| "verified"
| "not-found"
| "taken"
| "error";
interface ETradeInfoProps {
/** Current TIN value (drives button enablement). */
tin: string;
@@ -22,6 +22,8 @@ interface ETradeInfoProps {
/** Validation error for the TIN field, if any. */
error?: string;
onDataLoaded: (data: CompanyRegistrationData) => void;
/** Reports the live lookup status so the parent step can gate on it. */
onStatusChange?: (status: ETradeStatus) => void;
}
const isValidTin = (tin: string) => tin.length === 10;
@@ -31,12 +33,11 @@ export default function ETradeInfo({
register,
error,
onDataLoaded,
onStatusChange,
}: ETradeInfoProps) {
const mutation = useETradeData();
const isLoading = mutation.isPending;
const tinTaken = mutation.data?.tinTaken;
const hasData =
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
const handleFetch = async () => {
if (!isValidTin(tin)) return;
@@ -47,8 +48,10 @@ export default function ETradeInfo({
};
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
// once per distinct value, so retyping the same TIN doesn't refetch.
const lastFetchedTin = useRef<string | null>(null);
// once per distinct value, so retyping the same TIN doesn't refetch. Seeded
// from the initial value so a resumed draft with an already-verified TIN
// doesn't refire the lookup the moment this mounts.
const lastFetchedTin = useRef<string | null>(tin || null);
useEffect(() => {
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
lastFetchedTin.current = tin;
@@ -61,16 +64,36 @@ export default function ETradeInfo({
mutation.isError && mutation.error
? extractApiError(mutation.error)
: null;
// A 400 here means eTrade simply has no record for this TIN — not a
// failure. Soft-pedal it as an FYI, not a red error, so filling in
// manually doesn't feel like something went wrong.
// A 400 here means eTrade simply has no record for this TIN.
const notFound = apiError?.statusCode === 400;
const errorMessage =
apiError && !notFound
? apiError.message ||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
"We couldn't reach eTrade to fetch your company information. Please try again."
: null;
const status: ETradeStatus = isLoading
? "loading"
: tinTaken
? "taken"
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
? "verified"
: notFound
? "not-found"
: errorMessage
? "error"
: "idle";
const lastReportedStatus = useRef<ETradeStatus | null>(null);
useEffect(() => {
if (lastReportedStatus.current === status) return;
lastReportedStatus.current = status;
onStatusChange?.(status);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading";
return (
<Stack gap="md">
<Group align="flex-start" grow>
@@ -86,7 +109,7 @@ export default function ETradeInfo({
error={error}
{...register}
/>
{errorMessage && (
{showRetry && (
<Button
variant="filled"
color="edr-green"
@@ -103,9 +126,13 @@ export default function ETradeInfo({
</Group>
{notFound && (
<Alert icon={<Info size={16} />} color="gray">
We couldn't find a matching business record for this TIN — no
problem, just fill in the details below.
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="No matching business record"
>
This TIN isn't registered with eTrade. Check the number — we can't
continue without a matching business record.
</Alert>
)}
@@ -130,29 +157,6 @@ export default function ETradeInfo({
mistake.
</Alert>
)}
{hasData && (
<Alert
icon={<CheckCircle2 size={16} />}
color="green"
title="Company information loaded"
>
<Stack gap={0}>
<Text size="sm">
<strong>License:</strong> {hasData.licenceNumber}
</Text>
<Text size="sm">
<strong>Status:</strong> {hasData.statusDescription}
</Text>
{hasData.region && (
<Text size="sm">
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
{hasData.zone}, {hasData.region}
</Text>
)}
</Stack>
</Alert>
)}
</Stack>
);
}

View File

@@ -66,7 +66,8 @@ const STEP_META: Record<
company: {
icon: <Building2 size={20} />,
title: "Company Information",
description: "Tell us about your company and its registration details.",
description:
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
},
personnel: {
icon: <User size={20} />,

View File

@@ -4,7 +4,6 @@ import {
Divider,
Group,
Loader,
Select,
SimpleGrid,
Stack,
Text,
@@ -14,13 +13,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { getMinFiles } from "@/types/fileUploadSettings";
@@ -28,7 +26,9 @@ import { api } from "@/services/api";
import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import {
buildOnboardingSchema,
type CompanyStep,
@@ -45,7 +45,8 @@ import {
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
import StepSection from "./companyProfileForm/StepSection";
export default function CompanyProfileForm({
documentSettingCode,
@@ -113,6 +114,9 @@ export default function CompanyProfileForm({
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Live eTrade lookup status, reported up by ETradeInfo — drives the Continue
// gate on the company step.
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
@@ -197,7 +201,6 @@ export default function CompanyProfileForm({
companyName: "",
companyEmail: "",
companyPhone: "",
companyLocation: "",
companyAddress: "",
tinNumber: "",
vatNumber: "",
@@ -230,14 +233,9 @@ export default function CompanyProfileForm({
values: rehydrate ? toFormValues(rehydrate) : undefined,
});
// eTrade carries no email, so the company/contact email fields start blank.
// Seed them from the registering user's account email — but only while empty,
// so a typed or rehydrated value is never overwritten.
// The contact person's email still just seeds from the account and stays editable.
useEffect(() => {
if (!user?.email) return;
if (!watch("companyEmail")) {
setValue("companyEmail", user.email, { shouldValidate: true });
}
if (!watch("contactPersonEmail")) {
setValue("contactPersonEmail", user.email);
}
@@ -282,19 +280,10 @@ export default function CompanyProfileForm({
setValue("woreda", data.woreda);
setValue("kebele", data.kebele);
setValue("houseNo", data.houseNo);
setValue(
"companyPhone",
toEthiopianE164(data.regularPhone || data.mobilePhone),
);
// companyAddress is composed reactively from the address fields below, so
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
// compose it here.
// Pre-fill the company contact phone from eTrade's mobile number.
const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone);
if (mobile) {
setValue("companyPhone", mobile, { shouldValidate: true });
}
// compose it here. companyPhone is derived below (identity → eTrade →
// account), not set directly here.
setEtradeOwner({
name: data.managerName,
@@ -304,6 +293,29 @@ export default function CompanyProfileForm({
});
};
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
// is the highest-trust source (that's the whole point of verifying), eTrade's
// registered number and the account email/phone are the fallbacks used
// before verification happens.
useEffect(() => {
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
shouldValidate: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity?.owner.email, user.email]);
useEffect(() => {
setValue(
"companyPhone",
identity?.owner.phone ??
etradeOwner?.phone ??
toEthiopianE164(user.phoneNumber) ??
"",
{ shouldValidate: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]);
// "Same as …" links. A checked card prefills the target step's fields from the
// source step and disables them (kept mirrored while linked); unchecking clears
// them and re-enables editing.
@@ -369,10 +381,9 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no location/address of its own, so the linked
// PoA takes the company's — location as entered, address as composed from the
// company's address fields. Both stay mirrored while the link is checked.
const companyLocation = watch("companyLocation");
// The contact-person step has no address of its own, so the linked PoA takes
// the company's composed address. poaLocation (the city) stays typed on the
// PoA step — the company step no longer has a location field to mirror.
const companyAddress = watch("companyAddress");
useEffect(() => {
@@ -380,7 +391,6 @@ export default function CompanyProfileForm({
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaLocation", companyLocation ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -388,7 +398,6 @@ export default function CompanyProfileForm({
contactName,
contactEmail,
contactPhone,
companyLocation,
companyAddress,
]);
@@ -425,11 +434,11 @@ export default function CompanyProfileForm({
() =>
uploadSetting
? {
...uploadSetting,
fields: uploadSetting.fields.filter(
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
),
}
...uploadSetting,
fields: uploadSetting.fields.filter(
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
),
}
: undefined,
[uploadSetting],
);
@@ -522,6 +531,9 @@ export default function CompanyProfileForm({
"renewedTo",
]);
const hasRegistrationDetails = registration.some((v) => v && v.trim());
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
// the registration fields being populated at all is proof it passed before.
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
// Single source of truth for step sequence — navigation, labels and the
// progress bar all derive from this so adding/removing a step is one edit.
@@ -575,7 +587,10 @@ export default function CompanyProfileForm({
if (step === "documents") {
const docErrors = validateRequiredDocuments();
const licenseErrors = validateLicenses();
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
if (
Object.keys(docErrors).length > 0 ||
Object.keys(licenseErrors).length > 0
) {
setDocumentFieldErrors(docErrors);
setLicenseFieldErrors(licenseErrors);
setSaveError("Please upload all required documents before continuing.");
@@ -599,21 +614,30 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
// The owner's identity is proved outside the form state too, so it gates
// here rather than through zod.
if (
step === "company" &&
identity &&
((identity.faydaRequired && !identity.owner.verified) ||
(identity.passportRequired && !identity.owner.passportNumber))
) {
// The TIN must resolve to a real eTrade record before anything else on
// this step is even worth validating — gates here rather than through zod.
if (step === "company" && tinStatus === "taken") {
setSaveError(
identity.faydaRequired
? "Verify the company owner's identity with Fayda before continuing."
: "Add the company owner's passport number before continuing.",
"This TIN is already registered to another company account.",
);
return;
}
if (step === "company" && !tinVerified) {
setSaveError(
"We need to confirm your TIN with eTrade before continuing.",
);
return;
}
// Fayda verification is proved outside the form state, so it gates here
// rather than through zod. The passport number is a plain typed field —
// buildOnboardingSchema already requires it when passportRequired, so
// saveCurrentStep()'s trigger() below catches that; checking the stale
// server-side identity.owner.passportNumber here would block a value the
// user just typed but hasn't saved yet.
if (step === "company" && identity?.faydaRequired && !identity.owner.verified) {
setSaveError("Verify the company owner's identity with Fayda before continuing.");
return;
}
if (
step === "poa" &&
verifiedIdentity &&
@@ -657,167 +681,97 @@ export default function CompanyProfileForm({
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<Stack gap="sm">
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
/>
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={
watch("vatNumber")?.length === 10 && !errors.vatNumber
? "done"
: "todo"
}
>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
label="VAT Number"
placeholder="0012345678"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="VAT Number"
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
onVerified={() => onIdentityChange?.()}
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
: "Provide the company owner's passport number."
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
onVerified={() => onIdentityChange?.()}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={
tinVerified
? "done"
: tinStatus === "taken"
? "blocked"
: "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
/>
{tinVerified && (
<ETradeCompanyCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
{hasRegistrationDetails && (
<>
<Divider my="sm" />
<Group gap="xs" align="center">
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Text size="xs" c="dimmed">
from eTrade · read-only
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<ReadOnlyField
label="License Number"
value={watch("licenceNumber")}
/>
<ReadOnlyField
label="Status"
value={watch("statusDescription")}
/>
<ReadOnlyField
label="Date Registered"
value={watch("dateRegistered")}
/>
<ReadOnlyField
label="Renewal Date"
value={watch("renewalDate")}
/>
<ReadOnlyField
label="Renewed From"
value={watch("renewedFrom")}
/>
<ReadOnlyField
label="Renewed To"
value={watch("renewedTo")}
/>
</SimpleGrid>
</>
)}
<Divider my="sm" />
<Text fw={600} size="sm" c="edr-text">
Address Information
</Text>
<SimpleGrid cols={2} spacing="md">
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
placeholder="Select region"
required
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={errors.region?.message}
// "" (unresolved eTrade value, or a legacy row whose
// region isn't in the list) must read as "nothing picked".
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
<TextInput
label="Zone"
placeholder="EASTERN TIGRAY"
required
error={errors.zone?.message}
{...register("zone")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Woreda"
placeholder="EROB"
required
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
placeholder="ARAS"
required
error={errors.kebele?.message}
{...register("kebele")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="House No"
placeholder="House Number"
required
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
)}
</StepSection>
</Stack>
)}
@@ -940,42 +894,42 @@ export default function CompanyProfileForm({
/>
)}
{!verifiedIdentity && (
<>
<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")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</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>
</>
<>
<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")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</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>
</>
)}
{/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */}

View File

@@ -0,0 +1,151 @@
import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
import { Controller } from "react-hook-form";
import type {
Control,
FieldErrors,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import type { FormData } from "./schema";
import { ReadOnlyField } from "./ReadOnlyField";
/**
* One field of the verified-registration card: locked read-only once eTrade
* supplied a value, but falls back to an editable input when eTrade left it
* blank — otherwise a gap in eTrade's own data would leave the field
* permanently empty and the user stuck (zod requires all of these).
*/
function LockedField({
label,
name,
register,
watch,
errors,
}: {
label: string;
name: keyof FormData;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
}) {
const value = watch(name) as string | undefined;
if (value && value.trim()) {
return <ReadOnlyField label={label} value={value} />;
}
return (
<TextInput
label={label}
description="eTrade didn't provide this — please confirm"
error={errors[name]?.message as string | undefined}
{...register(name)}
/>
);
}
export default function ETradeCompanyCard({
tin,
register,
watch,
errors,
control,
}: {
tin: string;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
control: Control<FormData>;
}) {
const companyName = watch("companyName");
const region = watch("region");
return (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<Text fw={600} c="edr-text">
{companyName && companyName.trim() ? companyName : "Company record"}
</Text>
<Badge
size="sm"
variant="light"
color="green"
leftSection={<CheckCircle2 size={11} />}
>
Verified with eTrade
</Badge>
</Group>
<Text size="xs" c="edr-muted">
TIN {tin}
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{region && region.trim() ? (
<ReadOnlyField label="Region" value={region} />
) : (
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
description="eTrade didn't provide this — please confirm"
placeholder="Select region"
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={errors.region?.message}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
)}
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
</SimpleGrid>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
import { Badge, Group, Stack, Text } from "@mantine/core";
import { Check, X } from "lucide-react";
import type { ReactNode } from "react";
export type SectionStatus = "todo" | "done" | "blocked";
const STATUS_BADGE: Record<
SectionStatus,
{ color: string; label: string; icon?: ReactNode } | null
> = {
todo: null,
done: { color: "green", label: "Done", icon: <Check size={11} /> },
blocked: { color: "red", label: "Action needed", icon: <X size={11} /> },
};
/**
* One numbered section of the Company Information step — a title, an
* optional subtitle, a status badge, and its content. Purely presentational;
* the parent decides each section's status.
*/
export default function StepSection({
index,
title,
subtitle,
status,
children,
}: {
index: number;
title: string;
subtitle?: string;
status: SectionStatus;
children: ReactNode;
}) {
const badge = STATUS_BADGE[status];
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Group gap="sm" align="center">
<Text
fw={700}
size="sm"
c={status === "done" ? "edr-green" : "edr-text"}
style={{
width: 24,
height: 24,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1.5px solid var(--mantine-color-edr-border-0)",
flexShrink: 0,
}}
>
{index}
</Text>
<div>
<Text fw={600} size="sm" c="edr-text">
{title}
</Text>
{subtitle && (
<Text size="xs" c="edr-muted">
{subtitle}
</Text>
)}
</div>
</Group>
{badge && (
<Badge
size="sm"
variant="light"
color={badge.color}
leftSection={badge.icon}
>
{badge.label}
</Badge>
)}
</Group>
<div style={{ paddingLeft: 34 }}>
<Stack gap="sm">{children}</Stack>
</div>
</Stack>
);
}

View File

@@ -25,7 +25,6 @@ export function buildPayload(
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
@@ -58,7 +57,6 @@ export function stepPayload(
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
@@ -110,7 +108,6 @@ export function toFormValues(p: ProfileResponse): FormData {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",

View File

@@ -18,7 +18,6 @@ export const onboardingSchema = z.object({
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
@@ -154,7 +153,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"companyName",
"companyEmail",
"companyPhone",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",