Refactor phone input handling across onboarding and settings forms

- Replaced PhoneInput component with ControlledPhoneField for better integration with react-hook-form.
- Updated validation for phone numbers using isValidPhone function to ensure proper formatting.
- Removed country code handling from forms, simplifying phone number management.
- Introduced new phone field component with consistent styling and behavior.
- Added phone number validation on the backend using class-validator.
- Removed unused phone utility functions and cleaned up related code.
This commit is contained in:
Marshal
2026-06-21 10:34:40 +00:00
parent 171e02cf7f
commit dc708a2473
25 changed files with 645 additions and 568 deletions

View File

@@ -45,6 +45,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3", "minio": "7.1.3",
"pg": "^8.13.0", "pg": "^8.13.0",
"puppeteer": "^24.2.0", "puppeteer": "^24.2.0",

View File

@@ -0,0 +1,53 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
/**
* Country-aware phone validation. The value is expected as a full international
* number (E.164, e.g. "+251911223344"), so the country is derived from the
* value itself — no separate country field needed.
*/
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
return isValidPhoneNumber(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
}
}
/** Class-validator decorator wrapping the country-aware phone constraint. */
export function IsValidPhone(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsValidPhoneConstraint,
});
};
}
/**
* Normalize a phone string to canonical E.164. Returns the canonical form when
* parseable, otherwise the trimmed original (tolerant — never throws), or the
* value unchanged when empty/nullish.
*/
export function normalizeE164(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
const parsed = parsePhoneNumberFromString(value);
return parsed?.isValid() ? parsed.number : value.trim();
}

View File

@@ -10,6 +10,7 @@ import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service"; import { MinioService } from "../minio/minio.service";
import { ETradeService } from "./services/etrade.service"; import { ETradeService } from "./services/etrade.service";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto"; import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -86,7 +87,7 @@ export class CompaniesService {
fanNumber: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia", country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null, address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null, phone: normalizeE164(dto.companyPhone) ?? null,
email: dto.companyEmail ?? null, email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null, attributes: dto.attributes ?? null,
}); });
@@ -109,7 +110,7 @@ export class CompaniesService {
firstName: identity.firstName, firstName: identity.firstName,
lastName: identity.lastName, lastName: identity.lastName,
email: identity.email, email: identity.email,
phone: identity.phone, phone: normalizeE164(identity.phone) ?? identity.phone,
jobTitle: dto.jobTitle ?? null, jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true, isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType, activeProfileType,
@@ -209,7 +210,7 @@ export class CompaniesService {
firstName: identity.firstName, firstName: identity.firstName,
lastName: identity.lastName, lastName: identity.lastName,
email: identity.email, email: identity.email,
phone: identity.phone, phone: normalizeE164(identity.phone) ?? identity.phone,
isPrimaryContact: true, isPrimaryContact: true,
activeProfileType, activeProfileType,
onboardingStep: "company", onboardingStep: "company",
@@ -475,7 +476,8 @@ export class CompaniesService {
companyUpdates.nationality = dto.nationality; companyUpdates.nationality = dto.nationality;
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; if (dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined) if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation; companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) if (dto.companyAddress !== undefined)
@@ -503,15 +505,16 @@ export class CompaniesService {
if (dto.contactPersonEmail !== undefined) if (dto.contactPersonEmail !== undefined)
attrUpdates.contactPersonEmail = dto.contactPersonEmail; attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined) if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = dto.contactPersonPhone; attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.generalManagerName !== undefined) if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName; attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail; attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = dto.generalManagerPhone; attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation; attrUpdates.poaLocation = dto.poaLocation;
@@ -540,7 +543,7 @@ export class CompaniesService {
if (dto.houseNo !== undefined) if (dto.houseNo !== undefined)
companyUpdates.houseNo = dto.houseNo; companyUpdates.houseNo = dto.houseNo;
if (dto.etradePhone !== undefined) if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = dto.etradePhone; companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates; companyUpdates.attributes = attrUpdates;

View File

@@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity'; import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity'; import { ProfileType } from '../entities/company-profile.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CompanyProfileInputDto { export class CompanyProfileInputDto {
@IsEnum(ProfileType) @IsEnum(ProfileType)
@@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@IsValidPhone()
companyPhone?: string; companyPhone?: string;
@IsOptional() @IsOptional()

View File

@@ -1,5 +1,6 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CreateCompanyDto { export class CreateCompanyDto {
@IsString() @IsString()
@@ -37,6 +38,7 @@ export class CreateCompanyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@IsValidPhone()
phone?: string; phone?: string;
@IsOptional() @IsOptional()

View File

@@ -1,4 +1,5 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CreateExternalProfileDto { export class CreateExternalProfileDto {
@IsUUID() @IsUUID()
@@ -26,6 +27,7 @@ export class CreateExternalProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@IsValidPhone()
phone?: string; phone?: string;
@IsOptional() @IsOptional()

View File

@@ -1,5 +1,6 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity'; import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class UpdateProfileDto { export class UpdateProfileDto {
@IsOptional() @IsOptional()
@@ -19,6 +20,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@IsValidPhone()
companyPhone?: string; companyPhone?: string;
@IsOptional() @IsOptional()
@@ -60,6 +62,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsValidPhone()
contactPersonPhone?: string; contactPersonPhone?: string;
@IsOptional() @IsOptional()
@@ -72,6 +75,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsValidPhone()
generalManagerPhone?: string; generalManagerPhone?: string;
@IsOptional() @IsOptional()
@@ -80,6 +84,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsValidPhone()
poaPhone?: string; poaPhone?: string;
@IsOptional() @IsOptional()
@@ -151,5 +156,6 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@IsValidPhone()
etradePhone?: string; etradePhone?: string;
} }

View File

@@ -29,6 +29,7 @@
"react-dom": "19.2.6", "react-dom": "19.2.6",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",

View File

@@ -0,0 +1,119 @@
import { Input } from "@mantine/core";
import { forwardRef } from "react";
import {
Controller,
type Control,
type FieldValues,
type Path,
} from "react-hook-form";
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
import "react-phone-number-input/style.css";
import "./phone-field.css";
/** Re-exported for zod `.refine()` checks on phone fields. */
export const isValidPhone = (value?: string | null): boolean =>
!!value && isValidPhoneNumber(value);
/**
* The text input rendered inside react-phone-number-input, styled to match the
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
* the ref and accept native input props for the library to drive it.
*/
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function StyledInput(props, ref) {
return <input {...props} ref={ref} className="edr-phone-input" />;
},
);
export interface PhoneFieldProps {
label?: string;
value?: string;
onChange: (value: string | undefined) => void;
onBlur?: () => void;
error?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}
/**
* Professional phone input: searchable country selector (all countries, default
* Ethiopia), live formatting, emits a single E.164 value (e.g. +251912345678).
* Visually aligned with the portal's Mantine form fields.
*/
export function PhoneField({
label,
value,
onChange,
onBlur,
error,
required,
disabled,
placeholder = "912 345 678",
}: PhoneFieldProps) {
return (
<Input.Wrapper
label={label}
required={required}
error={error}
styles={{
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
}}
>
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
placeholder={placeholder}
inputComponent={StyledInput}
/>
</div>
</Input.Wrapper>
);
}
interface ControlledPhoneFieldProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}
/** RHF Controller wrapper so forms drop in one line. */
export function ControlledPhoneField<T extends FieldValues>({
control,
name,
label,
required,
disabled,
placeholder,
}: ControlledPhoneFieldProps<T>) {
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => (
<PhoneField
label={label}
required={required}
disabled={disabled}
placeholder={placeholder}
value={(field.value as string) ?? ""}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
);
}
export default PhoneField;

View File

@@ -1,47 +0,0 @@
import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core";
type InputPassthrough = Partial<TextInputProps>;
interface PhoneInputProps {
disabled?: boolean;
countryCode?: InputPassthrough;
phone?: InputPassthrough;
countryCodeError?: { message?: string };
phoneError?: { message?: string };
label?: string;
}
export default function PhoneInput({
disabled,
countryCode: countryCodeProps,
phone: phoneProps,
countryCodeError,
phoneError,
label = "Phone Number",
}: PhoneInputProps) {
const errorMsg = countryCodeError?.message ?? phoneError?.message;
return (
<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}
error={Boolean(countryCodeError)}
styles={{ input: { textAlign: "center" } }}
{...countryCodeProps}
/>
<TextInput
style={{ flex: 1 }}
placeholder="912345678"
disabled={disabled}
error={Boolean(phoneError)}
{...phoneProps}
/>
</Group>
{errorMsg && (
<Text size="xs" c="red.6">{errorMsg}</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,82 @@
/* Align react-phone-number-input with the portal's Mantine field styling:
44px height, 10px radius, edr border, brand-green focus ring. */
.edr-phone-wrapper .PhoneInput {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Country selector — a compact pill matching the input height/radius. */
.edr-phone-wrapper .PhoneInputCountry {
margin: 0;
padding: 0 10px;
height: 44px;
border: 1px solid #e6ecf2;
border-radius: 10px;
background: #fff;
display: flex;
align-items: center;
gap: 6px;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-wrapper .PhoneInputCountryIcon {
width: 22px;
height: 16px;
box-shadow: none;
}
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
color: #6b7c8e;
opacity: 0.8;
}
/* The number input itself. */
.edr-phone-input {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid #e6ecf2;
border-radius: 10px;
font-size: 14px;
color: #10202f;
background: #fff;
outline: none;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-input::placeholder {
color: #9aa8b5;
}
.edr-phone-input:focus {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-wrapper .PhoneInputCountry:focus-within {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-input:disabled,
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
opacity: 0.6;
cursor: not-allowed;
}
/* Error state mirrors Mantine's invalid styling. */
.edr-phone-wrapper--error .edr-phone-input,
.edr-phone-wrapper--error .PhoneInputCountry {
border-color: #e03131;
}
.edr-phone-wrapper--error .edr-phone-input:focus {
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
}

View File

@@ -34,10 +34,9 @@ import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { splitPhone } from "@/utils/phone";
import RoleLicenseStep, { import RoleLicenseStep, {
type RoleLicenseProfile, type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
@@ -54,8 +53,10 @@ type CompanyStep =
const onboardingSchema = z.object({ const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z
companyPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"), companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no // Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section. // standalone input — the granular fields live in the registration section.
@@ -85,15 +86,21 @@ const onboardingSchema = z.object({
.email("Invalid email address") .email("Invalid email address")
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
contactPersonPhone: z.string().min(1, "Contact person phone is required"), contactPersonPhone: z
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"), generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"), generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"), generalManagerPhone: z
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(), poaName: z.string().optional(),
poaPhone: z.string().optional(), poaPhone: z
poaPhoneCountryCode: z.string().optional(), .string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(), poaAddress: z.string().optional(),
poaEmail: z.string().optional(), poaEmail: z.string().optional(),
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
@@ -106,7 +113,6 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"companyName", "companyName",
"companyEmail", "companyEmail",
"companyPhone", "companyPhone",
"companyPhoneCountryCode",
"companyLocation", "companyLocation",
"companyAddress", "companyAddress",
"tinNumber", "tinNumber",
@@ -129,14 +135,12 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"generalManagerName", "generalManagerName",
"generalManagerEmail", "generalManagerEmail",
"generalManagerPhone", "generalManagerPhone",
"generalManagerPhoneCountryCode",
], ],
contact: [ contact: [
"contactPersonName", "contactPersonName",
"contactPersonPosition", "contactPersonPosition",
"contactPersonEmail", "contactPersonEmail",
"contactPersonPhone", "contactPersonPhone",
"contactPersonPhoneCountryCode",
], ],
poa: [], poa: [],
documents: [], documents: [],
@@ -147,7 +151,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return { return {
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: data.companyPhone,
companyLocation: data.companyLocation, companyLocation: data.companyLocation,
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
@@ -157,15 +161,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
contactPersonName: data.contactPersonName, contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined, contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined, contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName, generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail, generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined, poaName: data.poaName || undefined,
poaPhone: poaPhone: data.poaPhone || undefined,
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined, poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined, poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined, poaLocation: data.poaLocation || undefined,
@@ -180,7 +181,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
return { return {
companyName: d.companyName, companyName: d.companyName,
companyEmail: d.companyEmail, companyEmail: d.companyEmail,
companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, companyPhone: d.companyPhone,
companyLocation: d.companyLocation, companyLocation: d.companyLocation,
companyAddress: d.companyAddress, companyAddress: d.companyAddress,
tin: d.tinNumber, tin: d.tinNumber,
@@ -203,22 +204,19 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
return { return {
generalManagerName: d.generalManagerName, generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail, generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, generalManagerPhone: d.generalManagerPhone,
}; };
case "contact": case "contact":
return { return {
contactPersonName: d.contactPersonName, contactPersonName: d.contactPersonName,
contactPersonPosition: d.contactPersonPosition || undefined, contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined, contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, contactPersonPhone: d.contactPersonPhone,
}; };
case "poa": case "poa":
return { return {
poaName: d.poaName || undefined, poaName: d.poaName || undefined,
poaPhone: poaPhone: d.poaPhone || undefined,
d.poaPhone && d.poaPhoneCountryCode
? `${d.poaPhoneCountryCode}${d.poaPhone}`
: undefined,
poaEmail: d.poaEmail || undefined, poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined, poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined, poaAddress: d.poaAddress || undefined,
@@ -228,19 +226,14 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
} }
} }
/** Seed the form from previously-saved profile data (splitting combined phones). */ /** Seed the form from previously-saved profile data. */
function toFormValues(p: ProfileResponse): FormData { function toFormValues(p: ProfileResponse): FormData {
const companyPhone = splitPhone(p.companyPhone);
const contactPhone = splitPhone(p.contactPersonPhone);
const gmPhone = splitPhone(p.generalManagerPhone);
const poaPhone = splitPhone(p.poaPhone);
// The draft placeholder TIN ("D…") shouldn't show as a real value. // The draft placeholder TIN ("D…") shouldn't show as a real value.
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return { return {
companyName: p.companyName ?? "", companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "", companyEmail: p.companyEmail ?? "",
companyPhone: companyPhone.number, companyPhone: p.companyPhone ?? "",
companyPhoneCountryCode: companyPhone.countryCode,
companyLocation: p.companyLocation ?? "", companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "", companyAddress: p.companyAddress ?? "",
tinNumber: tin, tinNumber: tin,
@@ -261,15 +254,12 @@ function toFormValues(p: ProfileResponse): FormData {
contactPersonName: p.contactPersonName ?? "", contactPersonName: p.contactPersonName ?? "",
contactPersonPosition: p.contactPersonPosition ?? "", contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "", contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: contactPhone.number, contactPersonPhone: p.contactPersonPhone ?? "",
contactPersonPhoneCountryCode: contactPhone.countryCode,
generalManagerName: p.generalManagerName ?? "", generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "", generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: gmPhone.number, generalManagerPhone: p.generalManagerPhone ?? "",
generalManagerPhoneCountryCode: gmPhone.countryCode,
poaName: p.poaName ?? "", poaName: p.poaName ?? "",
poaPhone: poaPhone.number, poaPhone: p.poaPhone ?? "",
poaPhoneCountryCode: poaPhone.countryCode,
poaAddress: p.poaAddress ?? "", poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "", poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "", poaLocation: p.poaLocation ?? "",
@@ -357,6 +347,7 @@ export default function CompanyProfileForm({
const { const {
register, register,
control,
handleSubmit, handleSubmit,
trigger, trigger,
watch, watch,
@@ -368,7 +359,6 @@ export default function CompanyProfileForm({
companyName: "", companyName: "",
companyEmail: "", companyEmail: "",
companyPhone: "", companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "", companyLocation: "",
companyAddress: "", companyAddress: "",
tinNumber: "", tinNumber: "",
@@ -390,14 +380,11 @@ export default function CompanyProfileForm({
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
contactPersonPhone: "", contactPersonPhone: "",
contactPersonPhoneCountryCode: "+251",
generalManagerName: "", generalManagerName: "",
generalManagerEmail: "", generalManagerEmail: "",
generalManagerPhone: "", generalManagerPhone: "",
generalManagerPhoneCountryCode: "+251",
poaName: "", poaName: "",
poaPhone: "", poaPhone: "",
poaPhoneCountryCode: "+251",
poaAddress: "", poaAddress: "",
poaEmail: "", poaEmail: "",
poaLocation: "", poaLocation: "",
@@ -447,9 +434,7 @@ export default function CompanyProfileForm({
// Pre-fill the company contact phone from eTrade's mobile number. // Pre-fill the company contact phone from eTrade's mobile number.
const mobile = data.mobilePhone || data.regularPhone; const mobile = data.mobilePhone || data.regularPhone;
if (mobile) { if (mobile) {
const { number, countryCode } = splitPhone(mobile); setValue("companyPhone", mobile ?? "", { shouldValidate: true });
setValue("companyPhone", number);
setValue("companyPhoneCountryCode", countryCode);
} }
setEtradeOwner({ setEtradeOwner({
@@ -462,9 +447,9 @@ export default function CompanyProfileForm({
const useOwnerAsManager = () => { const useOwnerAsManager = () => {
if (!etradeOwner) return; if (!etradeOwner) return;
setValue("generalManagerName", etradeOwner.name); setValue("generalManagerName", etradeOwner.name);
const { number, countryCode } = splitPhone(etradeOwner.phone); setValue("generalManagerPhone", etradeOwner.phone ?? "", {
setValue("generalManagerPhone", number); shouldValidate: true,
setValue("generalManagerPhoneCountryCode", countryCode); });
}; };
/** Copy the General Manager into the Contact Person fields (toggleable). */ /** Copy the General Manager into the Contact Person fields (toggleable). */
@@ -474,10 +459,6 @@ export default function CompanyProfileForm({
setValue("contactPersonName", watch("generalManagerName")); setValue("contactPersonName", watch("generalManagerName"));
setValue("contactPersonEmail", watch("generalManagerEmail")); setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone")); setValue("contactPersonPhone", watch("generalManagerPhone"));
setValue(
"contactPersonPhoneCountryCode",
watch("generalManagerPhoneCountryCode"),
);
}; };
/** Copy the Contact Person into the PoA fields (toggleable, still editable). */ /** Copy the Contact Person into the PoA fields (toggleable, still editable). */
@@ -487,7 +468,6 @@ export default function CompanyProfileForm({
setValue("poaName", watch("contactPersonName")); setValue("poaName", watch("contactPersonName"));
setValue("poaEmail", watch("contactPersonEmail")); setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone")); setValue("poaPhone", watch("contactPersonPhone"));
setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode"));
}; };
const hasDocuments = Boolean(uploadSetting?.fields?.length); const hasDocuments = Boolean(uploadSetting?.fields?.length);
@@ -663,15 +643,11 @@ export default function CompanyProfileForm({
error={errors.companyEmail?.message} error={errors.companyEmail?.message}
{...register("companyEmail")} {...register("companyEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("companyPhoneCountryCode") }} control={control}
phone={{ name="companyPhone"
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
<TextInput <TextInput
@@ -828,17 +804,11 @@ export default function CompanyProfileForm({
error={errors.generalManagerEmail?.message} error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")} {...register("generalManagerEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ control={control}
...register("generalManagerPhoneCountryCode"), name="generalManagerPhone"
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone" label="Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
</> </>
@@ -877,15 +847,11 @@ export default function CompanyProfileForm({
error={errors.contactPersonEmail?.message} error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")} {...register("contactPersonEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("contactPersonPhoneCountryCode") }} control={control}
phone={{ name="contactPersonPhone"
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone" label="Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
</> </>
@@ -917,11 +883,9 @@ export default function CompanyProfileForm({
error={errors.poaEmail?.message} error={errors.poaEmail?.message}
{...register("poaEmail")} {...register("poaEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("poaPhoneCountryCode") }} control={control}
phone={{ ...register("poaPhone"), placeholder: "912345678" }} name="poaPhone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
label="PoA Phone" label="PoA Phone"
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -16,7 +16,7 @@ import { z } from "zod";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({ const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z
companyPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location / Country is required"), companyLocation: z.string().min(1, "Location / Country is required"),
companyAddress: z.string().min(1, "Address is required"), companyAddress: z.string().min(1, "Address is required"),
repName: z.string().min(1, "Representative name is required"), repName: z.string().min(1, "Representative name is required"),
repEmail: z.string().email("Invalid representative email"), repEmail: z.string().email("Invalid representative email"),
repPhone: z.string().min(1, "Representative phone is required"), repPhone: z
repPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
}); });
type FormData = z.infer<typeof djiboutiSchema>; type FormData = z.infer<typeof djiboutiSchema>;
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = { const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"], company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"],
representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"], representative: ["repName", "repEmail", "repPhone"],
documents: [], documents: [],
confirm: [], confirm: [],
}; };
@@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return { return {
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: data.companyPhone,
companyLocation: data.companyLocation, companyLocation: data.companyLocation,
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: "", tin: "",
@@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
attributes: { attributes: {
repName: data.repName, repName: data.repName,
repEmail: data.repEmail, repEmail: data.repEmail,
repPhone: `${data.repPhoneCountryCode}${data.repPhone}`, repPhone: data.repPhone,
}, },
}; };
} }
@@ -88,11 +92,11 @@ export default function DjiboutiAgentForm({
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, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(djiboutiSchema), resolver: zodResolver(djiboutiSchema),
defaultValues: { defaultValues: {
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253", companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253", companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "",
}, },
}); });
@@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({
error={errors.companyEmail?.message} error={errors.companyEmail?.message}
{...register("companyEmail")} {...register("companyEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("companyPhoneCountryCode") }} control={control}
phone={{ ...register("companyPhone"), placeholder: "12345678" }} name="companyPhone"
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
@@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({
error={errors.repEmail?.message} error={errors.repEmail?.message}
{...register("repEmail")} {...register("repEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("repPhoneCountryCode") }} control={control}
phone={{ ...register("repPhone"), placeholder: "12345678" }} name="repPhone"
countryCodeError={errors.repPhoneCountryCode}
phoneError={errors.repPhone}
label="Representative Phone" label="Representative Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
</> </>
@@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({
<ReviewRow label="Address" value={formValues.companyAddress} /> <ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} /> <ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} /> <ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow label="Rep. phone" value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`} /> <ReviewRow label="Rep. phone" value={formValues.repPhone} />
</SimpleGrid> </SimpleGrid>
</Box> </Box>
)} )}

View File

@@ -19,10 +19,9 @@ import { z } from "zod";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { splitPhone } from "@/utils/phone";
import RoleLicenseStep, { import RoleLicenseStep, {
type RoleLicenseProfile, type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
@@ -32,23 +31,31 @@ type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional
const forwarderSchema = z.object({ const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z
companyPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"), companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"), companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), 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"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"), contactPersonPhone: z
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"), generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"), generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"), generalManagerPhone: z
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(), poaName: z.string().optional(),
poaPhone: z.string().optional(), poaPhone: z
poaPhoneCountryCode: z.string().optional(), .string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(), poaAddress: z.string().optional(),
poaEmail: z.string().optional(), poaEmail: z.string().optional(),
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
@@ -57,8 +64,8 @@ const forwarderSchema = z.object({
type FormData = z.infer<typeof forwarderSchema>; type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = { const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"],
poa: [], poa: [],
documents: [], documents: [],
additional: [], additional: [],
@@ -68,7 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return { return {
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: data.companyPhone,
companyLocation: data.companyLocation, companyLocation: data.companyLocation,
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
@@ -76,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
fanNumber: data.fanNumber, fanNumber: data.fanNumber,
attributes: { attributes: {
contactPersonName: data.contactPersonName, contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName, generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail, generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined, poaName: data.poaName || undefined,
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined, poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined, poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined, poaLocation: data.poaLocation || undefined,
@@ -96,7 +103,7 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePay
return { return {
companyName: d.companyName, companyName: d.companyName,
companyEmail: d.companyEmail, companyEmail: d.companyEmail,
companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, companyPhone: d.companyPhone,
companyLocation: d.companyLocation, companyLocation: d.companyLocation,
companyAddress: d.companyAddress, companyAddress: d.companyAddress,
tin: d.tinNumber, tin: d.tinNumber,
@@ -106,18 +113,15 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePay
case "personnel": case "personnel":
return { return {
contactPersonName: d.contactPersonName, contactPersonName: d.contactPersonName,
contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, contactPersonPhone: d.contactPersonPhone,
generalManagerName: d.generalManagerName, generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail, generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, generalManagerPhone: d.generalManagerPhone,
}; };
case "poa": case "poa":
return { return {
poaName: d.poaName || undefined, poaName: d.poaName || undefined,
poaPhone: poaPhone: d.poaPhone || undefined,
d.poaPhone && d.poaPhoneCountryCode
? `${d.poaPhoneCountryCode}${d.poaPhone}`
: undefined,
poaEmail: d.poaEmail || undefined, poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined, poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined, poaAddress: d.poaAddress || undefined,
@@ -127,33 +131,25 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePay
} }
} }
/** Seed the form from previously-saved profile data (splitting combined phones). */ /** Seed the form from previously-saved profile data. */
function toFormValues(p: ProfileResponse): FormData { function toFormValues(p: ProfileResponse): FormData {
const companyPhone = splitPhone(p.companyPhone);
const contactPhone = splitPhone(p.contactPersonPhone);
const gmPhone = splitPhone(p.generalManagerPhone);
const poaPhone = splitPhone(p.poaPhone);
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return { return {
companyName: p.companyName ?? "", companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "", companyEmail: p.companyEmail ?? "",
companyPhone: companyPhone.number, companyPhone: p.companyPhone ?? "",
companyPhoneCountryCode: companyPhone.countryCode,
companyLocation: p.companyLocation ?? "", companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "", companyAddress: p.companyAddress ?? "",
tinNumber: tin, tinNumber: tin,
vatNumber: p.vatNumber ?? "", vatNumber: p.vatNumber ?? "",
fanNumber: p.fanNumber ?? "", fanNumber: p.fanNumber ?? "",
contactPersonName: p.contactPersonName ?? "", contactPersonName: p.contactPersonName ?? "",
contactPersonPhone: contactPhone.number, contactPersonPhone: p.contactPersonPhone ?? "",
contactPersonPhoneCountryCode: contactPhone.countryCode,
generalManagerName: p.generalManagerName ?? "", generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "", generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: gmPhone.number, generalManagerPhone: p.generalManagerPhone ?? "",
generalManagerPhoneCountryCode: gmPhone.countryCode,
poaName: p.poaName ?? "", poaName: p.poaName ?? "",
poaPhone: poaPhone.number, poaPhone: p.poaPhone ?? "",
poaPhoneCountryCode: poaPhone.countryCode,
poaAddress: p.poaAddress ?? "", poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "", poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "", poaLocation: p.poaLocation ?? "",
@@ -233,14 +229,14 @@ export default function ForwarderForm({
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, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(forwarderSchema), resolver: zodResolver(forwarderSchema),
defaultValues: { defaultValues: {
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", contactPersonName: "", contactPersonPhone: "",
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "",
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "",
}, },
// Rehydrate from previously-saved data (RHF re-syncs when `values` change). // Rehydrate from previously-saved data (RHF re-syncs when `values` change).
values: rehydrate ? toFormValues(rehydrate) : undefined, values: rehydrate ? toFormValues(rehydrate) : undefined,
@@ -383,12 +379,11 @@ export default function ForwarderForm({
error={errors.companyEmail?.message} error={errors.companyEmail?.message}
{...register("companyEmail")} {...register("companyEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("companyPhoneCountryCode") }} control={control}
phone={{ ...register("companyPhone"), placeholder: "912345678" }} name="companyPhone"
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
@@ -441,12 +436,11 @@ export default function ForwarderForm({
error={errors.contactPersonName?.message} error={errors.contactPersonName?.message}
{...register("contactPersonName")} {...register("contactPersonName")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("contactPersonPhoneCountryCode") }} control={control}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }} name="contactPersonPhone"
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone" label="Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
@@ -467,12 +461,11 @@ export default function ForwarderForm({
error={errors.generalManagerEmail?.message} error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")} {...register("generalManagerEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("generalManagerPhoneCountryCode") }} control={control}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }} name="generalManagerPhone"
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone" label="Phone"
required
/> />
</SimpleGrid> </SimpleGrid>
</> </>
@@ -497,11 +490,9 @@ export default function ForwarderForm({
error={errors.poaEmail?.message} error={errors.poaEmail?.message}
{...register("poaEmail")} {...register("poaEmail")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("poaPhoneCountryCode") }} control={control}
phone={{ ...register("poaPhone"), placeholder: "912345678" }} name="poaPhone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
label="PoA Phone" label="PoA Phone"
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -1,9 +1,12 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png"; const EDR_LOGO = "/assets/edr-logo.png";
@@ -25,7 +28,6 @@ export default function LoginPage() {
const { login } = useAuth(); const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email"); const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [countryCode] = useState("+251");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -38,11 +40,9 @@ export default function LoginPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const loginId = // In phone mode the identifier is already a canonical E.164 string
method === "email" // (e.g. +251912345678) from the phone field; email mode passes through.
? identifier const result = await login({ email: identifier, password });
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) { if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)?.from const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname; ?.pathname;
@@ -79,7 +79,10 @@ export default function LoginPage() {
<div className="relative"> <div className="relative">
<select <select
value={method} value={method}
onChange={(event) => setMethod(event.target.value as LoginMethod)} onChange={(event) => {
setMethod(event.target.value as LoginMethod);
setIdentifier("");
}}
disabled={loading} disabled={loading}
className={`${fieldClass} appearance-none pr-10`} className={`${fieldClass} appearance-none pr-10`}
> >
@@ -97,13 +100,29 @@ export default function LoginPage() {
<label className="text-sm font-medium text-gray-800"> <label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span> {currentMethod.label} <span className="text-red-500">*</span>
</label> </label>
<input {method === "phone" ? (
value={identifier} <div className="edr-phone-wrapper">
onChange={(event) => setIdentifier(event.target.value)} <RPNInput
placeholder={currentMethod.placeholder} international
disabled={loading} defaultCountry="ET"
className={fieldClass} countryCallingCodeEditable={false}
/> addInternationalOption
placeholder="912 345 678"
disabled={loading}
value={identifier || undefined}
onChange={(v) => setIdentifier(v ?? "")}
/>
</div>
) : (
<input
type="email"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
)}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -1,14 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import { userType } from "@/enums/userType"; import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth"; import type { SignupPayload } from "@/types/auth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import { isValidPhone } from "@/components/PhoneField";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png"; const EDR_LOGO = "/assets/edr-logo.png";
@@ -20,22 +24,13 @@ const passwordRequirements = [
{ 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; ] as const;
const ETHIOPIA_COUNTRY_CODE = "+251";
const isValidEthiopianMobile = (value: string) => {
const digits = value.replace(/\D/g, "");
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
return /^9\d{8}$/.test(normalized);
};
const userSchema = z const userSchema = z
.object({ .object({
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z phone: z
.string() .string()
.min(1, "Phone number is required") .min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"), .refine(isValidPhone, "Enter a valid phone number"),
userType: z.string(), userType: z.string(),
firstName: 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() }), lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -70,12 +65,12 @@ export default function SignupPage() {
register, register,
handleSubmit, handleSubmit,
watch, watch,
control,
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(userSchema), resolver: zodResolver(userSchema),
defaultValues: { defaultValues: {
email: "", email: "",
countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "", phone: "",
userType: userType.individual, userType: userType.individual,
firstName: { en: "", am: "" }, firstName: { en: "", am: "" },
@@ -89,12 +84,11 @@ export default function SignupPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const digits = data.phone.replace(/\D/g, "");
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = { const payload: SignupPayload = {
email: data.email, email: data.email,
username: data.email, username: data.email,
phoneNumber: `${data.countryCode}${normalizedPhone}`, // Already a canonical E.164 string from the phone field (e.g. +251912345678).
phoneNumber: data.phone,
userType: data.userType, userType: data.userType,
name: { name: {
en: `${data.firstName.en} ${data.lastName.en}`, en: `${data.firstName.en} ${data.lastName.en}`,
@@ -183,31 +177,30 @@ export default function SignupPage() {
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800"> <label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span> Phone <span className="text-red-500">*</span>
</label> </label>
<input type="hidden" {...register("countryCode")} /> <Controller
<div control={control}
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${ name="phone"
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90" render={({ field }) => (
}`} <div
> className={`edr-phone-wrapper${
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600"> errors.phone ? " edr-phone-wrapper--error" : ""
{ETHIOPIA_COUNTRY_CODE} }`}
</span> >
<input <RPNInput
id="signup-phone" international
type="tel" defaultCountry="ET"
inputMode="numeric" countryCallingCodeEditable={false}
autoComplete="tel-national" addInternationalOption
placeholder="0912345678" id="signup-phone"
maxLength={10} placeholder="912 345 678"
disabled={loading} disabled={loading}
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400" value={field.value || undefined}
{...register("phone", { onChange={(v) => field.onChange(v ?? "")}
onChange: (event) => { onBlur={field.onBlur}
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); />
}, </div>
})} )}
/> />
</div>
{errorText(errors.phone?.message)} {errorText(errors.phone?.message)}
</div> </div>

View File

@@ -11,7 +11,7 @@ import {
CheckCircle2, CheckCircle2,
} from "lucide-react"; } from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { import {
Button, Button,
@@ -37,9 +37,8 @@ const schema = z.object({
phoneNumber: z phoneNumber: z
.string() .string()
.min(1, "Phone number is required"), .min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
phoneCountryCode: z.string().min(1),
// COMPANY // COMPANY
companyName: z companyName: z
@@ -52,9 +51,8 @@ const schema = z.object({
companyPhone: z companyPhone: z
.string() .string()
.min(1, "Company phone is required"), .min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyPhoneCountryCode: z.string().min(1),
companyLocation: z companyLocation: z
.string() .string()
@@ -75,9 +73,8 @@ const schema = z.object({
representativePhone: z representativePhone: z
.string() .string()
.min(1, "Representative phone is required"), .min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
representativePhoneCountryCode: z.string().min(1),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
@@ -91,14 +88,12 @@ const stepFields: Record<
"lastName", "lastName",
"email", "email",
"phoneNumber", "phoneNumber",
"phoneCountryCode",
], ],
company: [ company: [
"companyName", "companyName",
"companyEmail", "companyEmail",
"companyPhone", "companyPhone",
"companyPhoneCountryCode",
"companyLocation", "companyLocation",
"companyAddress", "companyAddress",
], ],
@@ -107,7 +102,6 @@ const stepFields: Record<
"representativeName", "representativeName",
"representativeEmail", "representativeEmail",
"representativePhone", "representativePhone",
"representativePhoneCountryCode",
], ],
}; };
@@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
trigger, trigger,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
phoneCountryCode: "+253", phoneNumber: "",
companyPhoneCountryCode: "+253", companyPhone: "",
representativePhoneCountryCode: representativePhone: "",
"+253",
}, },
}); });
@@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number" label="Phone Number"
countryCode={{ required
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "77123456",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/> />
</div> </div>
</> </>
@@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone" label="Company Phone"
countryCode={{ required
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/> />
</div> </div>
@@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="representativePhone"
label="Representative Phone" label="Representative Phone"
countryCode={{ required
...register(
"representativePhoneCountryCode"
),
}}
phone={{
...register(
"representativePhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.representativePhoneCountryCode
}
phoneError={
errors.representativePhone
}
/> />
</div> </div>
</> </>

View File

@@ -12,7 +12,7 @@ import {
CheckCircle2, CheckCircle2,
} from "lucide-react"; } from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { import {
Button, Button,
@@ -34,14 +34,18 @@ const onboardingSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
phoneNumber: z.string().min(1, "Phone number is required"), phoneNumber: z
phoneCountryCode: z.string().min(1), .string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
// COMPANY // COMPANY
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z
companyPhoneCountryCode: z.string().min(1), .string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"), companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"), companyAddress: z.string().min(1, "Address is required"),
@@ -63,9 +67,8 @@ const onboardingSchema = z.object({
contactPersonPhone: z contactPersonPhone: z
.string() .string()
.min(1, "Contact person phone is required"), .min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
contactPersonPhoneCountryCode: z.string().min(1),
// GENERAL MANAGER // GENERAL MANAGER
generalManagerName: z generalManagerName: z
@@ -78,14 +81,15 @@ const onboardingSchema = z.object({
generalManagerPhone: z generalManagerPhone: z
.string() .string()
.min(1, "General manager phone is required"), .min(1, "General manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerPhoneCountryCode: z.string().min(1),
// OPTIONAL POA // OPTIONAL POA
poaName: z.string().optional(), poaName: z.string().optional(),
poaPhone: z.string().optional(), poaPhone: z
poaPhoneCountryCode: z.string().optional(), .string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(), poaAddress: z.string().optional(),
poaEmail: z.string().optional(), poaEmail: z.string().optional(),
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
@@ -102,14 +106,12 @@ const stepFields: Record<
"lastName", "lastName",
"email", "email",
"phoneNumber", "phoneNumber",
"phoneCountryCode",
], ],
company: [ company: [
"companyName", "companyName",
"companyEmail", "companyEmail",
"companyPhone", "companyPhone",
"companyPhoneCountryCode",
"companyLocation", "companyLocation",
"companyAddress", "companyAddress",
"tinNumber", "tinNumber",
@@ -120,11 +122,9 @@ const stepFields: Record<
personnel: [ personnel: [
"contactPersonName", "contactPersonName",
"contactPersonPhone", "contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName", "generalManagerName",
"generalManagerEmail", "generalManagerEmail",
"generalManagerPhone", "generalManagerPhone",
"generalManagerPhoneCountryCode",
], ],
poa: [], poa: [],
@@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
trigger, trigger,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() {
resolver: zodResolver(onboardingSchema), resolver: zodResolver(onboardingSchema),
defaultValues: { defaultValues: {
phoneCountryCode: "+251", phoneNumber: "",
companyPhoneCountryCode: "+251", companyPhone: "",
contactPersonPhoneCountryCode: "+251", contactPersonPhone: "",
generalManagerPhoneCountryCode: "+251", generalManagerPhone: "",
poaPhoneCountryCode: "+251", poaPhone: "",
}, },
}); });
@@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number" label="Phone Number"
countryCode={{ required
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "912345678",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/> />
</div> </div>
</> </>
@@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone" label="Company Phone"
countryCode={{ required
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/> />
</div> </div>
@@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Contact Person Phone" label="Contact Person Phone"
countryCode={{ required
...register(
"contactPersonPhoneCountryCode"
),
}}
phone={{
...register(
"contactPersonPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.contactPersonPhoneCountryCode
}
phoneError={
errors.contactPersonPhone
}
/> />
</div> </div>
</div> </div>
@@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="generalManagerPhone"
label="General Manager Phone" label="General Manager Phone"
countryCode={{ required
...register(
"generalManagerPhoneCountryCode"
),
}}
phone={{
...register(
"generalManagerPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.generalManagerPhoneCountryCode
}
phoneError={
errors.generalManagerPhone
}
/> />
</div> </div>
</div> </div>
@@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() {
/> />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone" label="PoA Phone"
countryCode={{
...register(
"poaPhoneCountryCode"
),
}}
phone={{
...register("poaPhone"),
placeholder: "912345678",
}}
/> />
</div> </div>

View File

@@ -11,7 +11,7 @@ import {
CheckCircle2, CheckCircle2,
} from "lucide-react"; } from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { import {
Button, Button,
@@ -29,8 +29,10 @@ const schema = z.object({
firstName: z.string().min(1), firstName: z.string().min(1),
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phoneNumber: z.string().min(1), phoneNumber: z
phoneCountryCode: z.string().min(1), .string()
.min(1)
.refine(isValidPhone, "Enter a valid phone number"),
// TRANSPORT // TRANSPORT
fanNumber: z.string().min(1), fanNumber: z.string().min(1),
@@ -60,7 +62,6 @@ const stepFields: Record<Step, (keyof FormData)[]> = {
"lastName", "lastName",
"email", "email",
"phoneNumber", "phoneNumber",
"phoneCountryCode",
], ],
transport: [ transport: [
"fanNumber", "fanNumber",
@@ -78,6 +79,7 @@ export default function TransporterOnboarding() {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
trigger, trigger,
watch, watch,
@@ -85,7 +87,7 @@ export default function TransporterOnboarding() {
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
phoneCountryCode: "+251", phoneNumber: "",
}, },
}); });
@@ -161,12 +163,11 @@ export default function TransporterOnboarding() {
<FieldError errors={[errors.email]} /> <FieldError errors={[errors.email]} />
</Field> </Field>
<PhoneInput <ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number" label="Phone Number"
countryCode={{ ...register("phoneCountryCode") }} required
phone={{ ...register("phoneNumber") }}
countryCodeError={errors.phoneCountryCode}
phoneError={errors.phoneNumber}
/> />
</div> </div>
</> </>

View File

@@ -15,7 +15,7 @@ import {
Grid, Grid,
} from "@mantine/core"; } from "@mantine/core";
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import type { import type {
CreateCompanyPayload, CreateCompanyPayload,
@@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({ export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z
companyPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"), companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"), companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>; export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
export function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabCompanyProfileProps { interface TabCompanyProfileProps {
profile?: ProfileResponse; profile?: ProfileResponse;
mode?: "edit" | "create"; mode?: "edit" | "create";
@@ -61,12 +56,10 @@ export default function TabCompanyProfile({
const defaultValues = useMemo((): CompanyProfileFormData => { const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) { if (profile) {
const phone = splitPhone(profile.companyPhone);
return { return {
companyName: profile.companyName, companyName: profile.companyName,
companyEmail: profile.companyEmail ?? "", companyEmail: profile.companyEmail ?? "",
companyPhone: phone.number, companyPhone: profile.companyPhone ?? "",
companyPhoneCountryCode: phone.code,
companyLocation: profile.companyLocation, companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "", companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber, tinNumber: profile.tinNumber,
@@ -77,7 +70,6 @@ export default function TabCompanyProfile({
companyName: "", companyName: "",
companyEmail: "", companyEmail: "",
companyPhone: "", companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "", companyLocation: "",
companyAddress: "", companyAddress: "",
tinNumber: "", tinNumber: "",
@@ -87,6 +79,7 @@ export default function TabCompanyProfile({
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
@@ -100,7 +93,7 @@ export default function TabCompanyProfile({
const base = { const base = {
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: data.companyPhone,
companyLocation: data.companyLocation, companyLocation: data.companyLocation,
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
@@ -185,15 +178,11 @@ export default function TabCompanyProfile({
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("companyPhoneCountryCode") }} control={control}
phone={{ name="companyPhone"
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
required
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>

View File

@@ -14,24 +14,19 @@ import {
Button, Button,
} from "@mantine/core"; } from "@mantine/core";
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"), contactPersonPhone: z
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabContactPersonProps { interface TabContactPersonProps {
profile: ProfileResponse; profile: ProfileResponse;
mode?: "edit" | "onboarding"; mode?: "edit" | "onboarding";
@@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.contactPersonPhone);
return { return {
contactPersonName: profile.contactPersonName ?? "", contactPersonName: profile.contactPersonName ?? "",
contactPersonPhone: phone.number, contactPersonPhone: profile.contactPersonPhone ?? "",
contactPersonPhoneCountryCode: phone.code,
}; };
}, [profile]); }, [profile]);
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
@@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
mutationFn: (data: FormData) => mutationFn: (data: FormData) =>
api.companies.updateProfile.call({ api.companies.updateProfile.call({
contactPersonName: data.contactPersonName, contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, contactPersonPhone: data.contactPersonPhone,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
@@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
{...register("contactPersonName")} {...register("contactPersonName")}
/> />
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("contactPersonPhoneCountryCode") }} control={control}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }} name="contactPersonPhone"
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone Number" label="Phone Number"
required
/> />
</Stack> </Stack>

View File

@@ -15,25 +15,20 @@ import {
Grid, Grid,
} from "@mantine/core"; } from "@mantine/core";
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
generalManagerName: z.string().min(1, "GM name is required"), generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"), generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"), generalManagerPhone: z
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), .string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabGeneralManagerProps { interface TabGeneralManagerProps {
profile: ProfileResponse; profile: ProfileResponse;
mode?: "edit" | "onboarding"; mode?: "edit" | "onboarding";
@@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.generalManagerPhone);
return { return {
generalManagerName: profile.generalManagerName ?? "", generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "", generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: phone.number, generalManagerPhone: profile.generalManagerPhone ?? "",
generalManagerPhoneCountryCode: phone.code,
}; };
}, [profile]); }, [profile]);
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
@@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
api.companies.updateProfile.call({ api.companies.updateProfile.call({
generalManagerName: data.generalManagerName, generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail, generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, generalManagerPhone: data.generalManagerPhone,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
@@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("generalManagerPhoneCountryCode") }} control={control}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }} name="generalManagerPhone"
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone Number" label="Phone Number"
required
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>

View File

@@ -15,27 +15,22 @@ import {
Grid, Grid,
} from "@mantine/core"; } from "@mantine/core";
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
poaName: z.string().optional(), poaName: z.string().optional(),
poaEmail: z.string().optional(), poaEmail: z.string().optional(),
poaPhone: z.string().optional(), poaPhone: z
poaPhoneCountryCode: z.string().optional(), .string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
poaAddress: z.string().optional(), poaAddress: z.string().optional(),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabPowerOfAttorneyProps { interface TabPowerOfAttorneyProps {
profile: ProfileResponse; profile: ProfileResponse;
mode?: "edit" | "onboarding"; mode?: "edit" | "onboarding";
@@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.poaPhone);
return { return {
poaName: profile.poaName ?? "", poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "", poaEmail: profile.poaEmail ?? "",
poaPhone: phone.number, poaPhone: profile.poaPhone ?? "",
poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
poaLocation: profile.poaLocation ?? "", poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "", poaAddress: profile.poaAddress ?? "",
}; };
@@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
@@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({
mutationFn: (data: FormData) => mutationFn: (data: FormData) =>
api.companies.updateProfile.call({ api.companies.updateProfile.call({
poaName: data.poaName || undefined, poaName: data.poaName || undefined,
poaPhone: poaPhone: data.poaPhone || undefined,
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaEmail: data.poaEmail || undefined, poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined, poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined, poaAddress: data.poaAddress || undefined,
@@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<PhoneInput <ControlledPhoneField
countryCode={{ ...register("poaPhoneCountryCode") }} control={control}
phone={{ ...register("poaPhone"), placeholder: "912345678" }} name="poaPhone"
label="PoA Phone" label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>

View File

@@ -1,25 +0,0 @@
/**
* Phone numbers are stored combined as `{countryCode}{number}`
* (e.g. "+251912345678"). These helpers split a stored value back into the two
* fields the onboarding forms use, and combine them on the way out.
*/
const DEFAULT_COUNTRY_CODE = "+251";
/** Split a stored phone into { countryCode, number } for form rehydration. */
export function splitPhone(
value: string | null | undefined,
defaultCode = DEFAULT_COUNTRY_CODE,
): { countryCode: string; number: string } {
if (!value) return { countryCode: defaultCode, number: "" };
const trimmed = value.trim();
// Ethiopian (+251) is the common case; otherwise take the leading "+NNN".
const match = trimmed.match(/^(\+\d{1,4})(.*)$/);
if (match) return { countryCode: match[1], number: match[2] };
return { countryCode: defaultCode, number: trimmed };
}
/** Combine a country code + number into the stored phone form. */
export function combinePhone(countryCode: string, number: string): string {
return `${countryCode}${number}`;
}

50
pnpm-lock.yaml generated
View File

@@ -107,6 +107,9 @@ importers:
handlebars: handlebars:
specifier: ^4.7.9 specifier: ^4.7.9
version: 4.7.9 version: 4.7.9
libphonenumber-js:
specifier: ^1.13.6
version: 1.13.6
minio: minio:
specifier: 7.1.3 specifier: 7.1.3
version: 7.1.3 version: 7.1.3
@@ -349,6 +352,9 @@ importers:
react-hot-toast: react-hot-toast:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: react-router-dom:
specifier: ^6.27.0 specifier: ^6.27.0
version: 6.30.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 6.30.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -5915,6 +5921,9 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cli-cursor@3.1.0: cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6200,6 +6209,9 @@ packages:
typescript: typescript:
optional: true optional: true
country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
crc-32@1.2.2: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -7946,6 +7958,17 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
input-format@0.3.14:
resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==}
peerDependencies:
react: '>=18.1.0'
react-dom: '>=18.1.0'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
internal-ip@1.2.0: internal-ip@1.2.0:
resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==} resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -10490,6 +10513,12 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-phone-number-input@3.4.17:
resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-redux@9.3.0: react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies: peerDependencies:
@@ -19678,6 +19707,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
classnames@2.5.1: {}
cli-cursor@3.1.0: cli-cursor@3.1.0:
dependencies: dependencies:
restore-cursor: 3.1.0 restore-cursor: 3.1.0
@@ -19944,6 +19975,8 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
country-flag-icons@1.6.17: {}
crc-32@1.2.2: {} crc-32@1.2.2: {}
crc32-stream@4.0.3: crc32-stream@4.0.3:
@@ -22131,6 +22164,13 @@ snapshots:
ini@4.1.1: {} ini@4.1.1: {}
input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
prop-types: 15.8.1
optionalDependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
internal-ip@1.2.0: internal-ip@1.2.0:
dependencies: dependencies:
meow: 3.7.0 meow: 3.7.0
@@ -25075,6 +25115,16 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.31 '@types/react': 18.3.31
react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
classnames: 2.5.1
country-flag-icons: 1.6.17
input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
libphonenumber-js: 1.13.6
prop-types: 15.8.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1): react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1):
dependencies: dependencies:
'@types/use-sync-external-store': 0.0.6 '@types/use-sync-external-store': 0.0.6