From dc708a2473f5d1012d441d92564e374557f555e7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 21 Jun 2026 10:34:40 +0000 Subject: [PATCH] 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. --- apps/edr-freight-api/package.json | 1 + .../validators/is-phone-number.validator.ts | 53 +++++++ .../modules/companies/companies.service.ts | 19 ++- .../dto/create-company-with-profile.dto.ts | 2 + .../companies/dto/create-company.dto.ts | 2 + .../dto/create-external-profile.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 6 + apps/edr-freight-web/portal/package.json | 1 + .../portal/src/components/PhoneField.tsx | 119 +++++++++++++++ .../portal/src/components/auth/PhoneInput.tsx | 47 ------ .../portal/src/components/phone-field.css | 82 ++++++++++ .../src/pages/accounts/CompanyProfileForm.tsx | 136 ++++++----------- .../src/pages/accounts/DjiboutiAgentForm.tsx | 48 +++--- .../src/pages/accounts/ForwarderForm.tsx | 113 +++++++------- .../portal/src/pages/accounts/LoginPage.tsx | 47 ++++-- .../portal/src/pages/accounts/SignupPage.tsx | 73 ++++----- .../DjiboutiFreightForwardingAgent.tsx | 92 +++-------- .../on_boarding/ImportExportOnBoarding.tsx | 144 +++++------------- .../on_boarding/TransportrOnBoarding.tsx | 21 +-- .../src/pages/settings/TabCompanyProfile.tsx | 35 ++--- .../src/pages/settings/TabContactPerson.tsx | 31 ++-- .../src/pages/settings/TabGeneralManager.tsx | 31 ++-- .../src/pages/settings/TabPowerOfAttorney.tsx | 33 ++-- .../edr-freight-web/portal/src/utils/phone.ts | 25 --- pnpm-lock.yaml | 50 ++++++ 25 files changed, 645 insertions(+), 568 deletions(-) create mode 100644 apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts create mode 100644 apps/edr-freight-web/portal/src/components/PhoneField.tsx delete mode 100644 apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx create mode 100644 apps/edr-freight-web/portal/src/components/phone-field.css delete mode 100644 apps/edr-freight-web/portal/src/utils/phone.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 506ec86f0..115d3624a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -45,6 +45,7 @@ "class-validator": "^0.14.1", "dotenv": "^17.4.2", "handlebars": "^4.7.9", + "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", "puppeteer": "^24.2.0", diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts new file mode 100644 index 000000000..4f8dac4c7 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -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(); +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 69c4c3cae..78525d2c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -10,6 +10,7 @@ import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { ETradeService } from "./services/etrade.service"; +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -86,7 +87,7 @@ export class CompaniesService { fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, - phone: dto.companyPhone ?? null, + phone: normalizeE164(dto.companyPhone) ?? null, email: dto.companyEmail ?? null, attributes: dto.attributes ?? null, }); @@ -109,7 +110,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, @@ -209,7 +210,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", @@ -475,7 +476,8 @@ export class CompaniesService { companyUpdates.nationality = dto.nationality; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; 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) companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) @@ -503,15 +505,16 @@ export class CompaniesService { if (dto.contactPersonEmail !== undefined) attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) - attrUpdates.contactPersonPhone = dto.contactPersonPhone; + attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; if (dto.generalManagerPhone !== undefined) - attrUpdates.generalManagerPhone = dto.generalManagerPhone; + attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); 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.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; @@ -540,7 +543,7 @@ export class CompaniesService { if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo; if (dto.etradePhone !== undefined) - companyUpdates.etradePhone = dto.etradePhone; + companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index aa0bb72a2..eb32f72ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 5718f541e..0a699fe5e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateCompanyDto { @IsString() @@ -37,6 +38,7 @@ export class CreateCompanyDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index c694a50e0..7a9b94c44 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateExternalProfileDto { @IsUUID() @@ -26,6 +27,7 @@ export class CreateExternalProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 8bb691a80..d94cb5f35 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class UpdateProfileDto { @IsOptional() @@ -19,6 +20,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() @@ -60,6 +62,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() contactPersonPhone?: string; @IsOptional() @@ -72,6 +75,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() generalManagerPhone?: string; @IsOptional() @@ -80,6 +84,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() poaPhone?: string; @IsOptional() @@ -151,5 +156,6 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index f5fddd009..d7b458b19 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -29,6 +29,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", + "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx new file mode 100644 index 000000000..e1ad3cd56 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -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>( + function StyledInput(props, ref) { + return ; + }, +); + +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 ( + +
+ +
+
+ ); +} + +interface ControlledPhoneFieldProps { + control: Control; + name: Path; + label?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** RHF Controller wrapper so forms drop in one line. */ +export function ControlledPhoneField({ + control, + name, + label, + required, + disabled, + placeholder, +}: ControlledPhoneFieldProps) { + return ( + ( + field.onChange(v ?? "")} + onBlur={field.onBlur} + error={fieldState.error?.message} + /> + )} + /> + ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx deleted file mode 100644 index 556f8e309..000000000 --- a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core"; - -type InputPassthrough = Partial; - -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 ( - - {label} - - - - - {errorMsg && ( - {errorMsg} - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css new file mode 100644 index 000000000..2fd037b99 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -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); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 9c2c4001d..f64fd7efd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -34,10 +34,9 @@ import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -54,8 +53,10 @@ type CompanyStep = const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. @@ -85,15 +86,21 @@ const onboardingSchema = z.object({ .email("Invalid email address") .optional() .or(z.literal("")), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -106,7 +113,6 @@ const stepFields: Record = { "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -129,14 +135,12 @@ const stepFields: Record = { "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], contact: [ "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", - "contactPersonPhoneCountryCode", ], poa: [], documents: [], @@ -147,7 +151,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -157,15 +161,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -180,7 +181,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); - const { number, countryCode } = splitPhone(etradeOwner.phone); - setValue("generalManagerPhone", number); - setValue("generalManagerPhoneCountryCode", countryCode); + setValue("generalManagerPhone", etradeOwner.phone ?? "", { + shouldValidate: true, + }); }; /** Copy the General Manager into the Contact Person fields (toggleable). */ @@ -474,10 +459,6 @@ export default function CompanyProfileForm({ setValue("contactPersonName", watch("generalManagerName")); setValue("contactPersonEmail", watch("generalManagerEmail")); setValue("contactPersonPhone", watch("generalManagerPhone")); - setValue( - "contactPersonPhoneCountryCode", - watch("generalManagerPhoneCountryCode"), - ); }; /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ @@ -487,7 +468,6 @@ export default function CompanyProfileForm({ setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); - setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode")); }; const hasDocuments = Boolean(uploadSetting?.fields?.length); @@ -663,15 +643,11 @@ export default function CompanyProfileForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - - @@ -877,15 +847,11 @@ export default function CompanyProfileForm({ error={errors.contactPersonEmail?.message} {...register("contactPersonEmail")} /> - @@ -917,11 +883,9 @@ export default function CompanyProfileForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index eb58b98c1..89570c545 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -16,7 +16,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; 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 { api } from "@/services/api"; @@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm"; const djiboutiSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location / Country is required"), companyAddress: z.string().min(1, "Address is required"), repName: z.string().min(1, "Representative name is required"), repEmail: z.string().email("Invalid representative email"), - repPhone: z.string().min(1, "Representative phone is required"), - repPhoneCountryCode: z.string().min(1, "Country code is required"), + repPhone: z + .string() + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"], - representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"], + representative: ["repName", "repEmail", "repPhone"], documents: [], confirm: [], }; @@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: "", @@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { attributes: { repName: data.repName, 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 }), ); - const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(djiboutiSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253", - companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253", + companyName: "", companyEmail: "", companyPhone: "", + companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", }, }); @@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({ error={errors.repEmail?.message} {...register("repEmail")} /> - @@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({ - + )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index a2867a24f..e13ec2282 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -19,10 +19,9 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; 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 { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -32,23 +31,31 @@ type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -57,8 +64,8 @@ const forwarderSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], - personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], + personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"], poa: [], documents: [], additional: [], @@ -68,7 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -76,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -96,7 +103,7 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(forwarderSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", + companyName: "", companyEmail: "", companyPhone: "", companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", - contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", - generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", - poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", + contactPersonName: "", contactPersonPhone: "", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", + poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "", }, // Rehydrate from previously-saved data (RHF re-syncs when `values` change). values: rehydrate ? toFormValues(rehydrate) : undefined, @@ -383,12 +379,11 @@ export default function ForwarderForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -441,12 +436,11 @@ export default function ForwarderForm({ error={errors.contactPersonName?.message} {...register("contactPersonName")} /> - @@ -467,12 +461,11 @@ export default function ForwarderForm({ error={errors.generalManagerEmail?.message} {...register("generalManagerEmail")} /> - @@ -497,11 +490,9 @@ export default function ForwarderForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 458843a57..ac13f4c59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,12 @@ import { type FormEvent, useState } from "react"; import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; 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 AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -25,7 +28,6 @@ export default function LoginPage() { const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); - const [countryCode] = useState("+251"); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); @@ -38,11 +40,9 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const loginId = - method === "email" - ? identifier - : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`; - const result = await login({ email: loginId, password }); + // In phone mode the identifier is already a canonical E.164 string + // (e.g. +251912345678) from the phone field; email mode passes through. + const result = await login({ email: identifier, password }); if (result.success) { const from = (location.state as { from?: { pathname: string } } | null)?.from ?.pathname; @@ -79,7 +79,10 @@ export default function LoginPage() {
setIdentifier(event.target.value)} - placeholder={currentMethod.placeholder} - disabled={loading} - className={fieldClass} - /> + {method === "phone" ? ( +
+ setIdentifier(v ?? "")} + /> +
+ ) : ( + setIdentifier(event.target.value)} + placeholder={currentMethod.placeholder} + disabled={loading} + className={fieldClass} + /> + )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 43a131e90..723591597 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; 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 { z } from "zod"; +import RPNInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; 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"; @@ -20,22 +24,13 @@ const passwordRequirements = [ { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, ] 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 .object({ email: z.string().email("Invalid email address"), - countryCode: z.literal(ETHIOPIA_COUNTRY_CODE), phone: z .string() .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(), 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() }), @@ -70,12 +65,12 @@ export default function SignupPage() { register, handleSubmit, watch, + control, formState: { errors }, } = useForm({ resolver: zodResolver(userSchema), defaultValues: { email: "", - countryCode: ETHIOPIA_COUNTRY_CODE, phone: "", userType: userType.individual, firstName: { en: "", am: "" }, @@ -89,12 +84,11 @@ export default function SignupPage() { setError(null); setLoading(true); try { - const digits = data.phone.replace(/\D/g, ""); - const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits; const payload: SignupPayload = { email: 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, name: { en: `${data.firstName.en} ${data.lastName.en}`, @@ -183,31 +177,30 @@ export default function SignupPage() { - -
- - {ETHIOPIA_COUNTRY_CODE} - - { - event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); - }, - })} - /> -
+ ( +
+ field.onChange(v ?? "")} + onBlur={field.onBlur} + /> +
+ )} + /> {errorText(errors.phone?.message)}
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx index 93e2215a7..f7955a0c0 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -37,9 +37,8 @@ const schema = z.object({ phoneNumber: z .string() - .min(1, "Phone number is required"), - - phoneCountryCode: z.string().min(1), + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z @@ -52,9 +51,8 @@ const schema = z.object({ companyPhone: z .string() - .min(1, "Company phone is required"), - - companyPhoneCountryCode: z.string().min(1), + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z .string() @@ -75,9 +73,8 @@ const schema = z.object({ representativePhone: z .string() - .min(1, "Representative phone is required"), - - representativePhoneCountryCode: z.string().min(1), + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; @@ -91,14 +88,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", ], @@ -107,7 +102,6 @@ const stepFields: Record< "representativeName", "representativeEmail", "representativePhone", - "representativePhoneCountryCode", ], }; @@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() { resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+253", - companyPhoneCountryCode: "+253", - representativePhoneCountryCode: - "+253", + phoneNumber: "", + companyPhone: "", + representativePhone: "", }, }); @@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx index 92ef7bac8..33a28f58d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx @@ -12,7 +12,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -34,14 +34,18 @@ const onboardingSchema = z.object({ firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), - phoneNumber: z.string().min(1, "Phone number is required"), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), @@ -63,9 +67,8 @@ const onboardingSchema = z.object({ contactPersonPhone: z .string() - .min(1, "Contact person phone is required"), - - contactPersonPhoneCountryCode: z.string().min(1), + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // GENERAL MANAGER generalManagerName: z @@ -78,14 +81,15 @@ const onboardingSchema = z.object({ generalManagerPhone: z .string() - .min(1, "General manager phone is required"), - - generalManagerPhoneCountryCode: z.string().min(1), + .min(1, "General manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // OPTIONAL POA poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -102,14 +106,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -120,11 +122,9 @@ const stepFields: Record< personnel: [ "contactPersonName", "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], poa: [], @@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() { resolver: zodResolver(onboardingSchema), defaultValues: { - phoneCountryCode: "+251", - companyPhoneCountryCode: "+251", - contactPersonPhoneCountryCode: "+251", - generalManagerPhoneCountryCode: "+251", - poaPhoneCountryCode: "+251", + phoneNumber: "", + companyPhone: "", + contactPersonPhone: "", + generalManagerPhone: "", + poaPhone: "", }, }); @@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() { /> - @@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() { /> - @@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() { /> - @@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() { /> - @@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx index 53ea34260..20bd2249d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -29,8 +29,10 @@ const schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), - phoneNumber: z.string().min(1), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1) + .refine(isValidPhone, "Enter a valid phone number"), // TRANSPORT fanNumber: z.string().min(1), @@ -60,7 +62,6 @@ const stepFields: Record = { "lastName", "email", "phoneNumber", - "phoneCountryCode", ], transport: [ "fanNumber", @@ -78,6 +79,7 @@ export default function TransporterOnboarding() { const { register, + control, handleSubmit, trigger, watch, @@ -85,7 +87,7 @@ export default function TransporterOnboarding() { } = useForm({ resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+251", + phoneNumber: "", }, }); @@ -161,12 +163,11 @@ export default function TransporterOnboarding() { - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index f02740664..1f49baf7c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -15,7 +15,7 @@ import { Grid, } from "@mantine/core"; 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 { CreateCompanyPayload, @@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), @@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -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 { profile?: ProfileResponse; mode?: "edit" | "create"; @@ -61,12 +56,10 @@ export default function TabCompanyProfile({ const defaultValues = useMemo((): CompanyProfileFormData => { if (profile) { - const phone = splitPhone(profile.companyPhone); return { companyName: profile.companyName, companyEmail: profile.companyEmail ?? "", - companyPhone: phone.number, - companyPhoneCountryCode: phone.code, + companyPhone: profile.companyPhone ?? "", companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, @@ -77,7 +70,6 @@ export default function TabCompanyProfile({ companyName: "", companyEmail: "", companyPhone: "", - companyPhoneCountryCode: "+251", companyLocation: "", companyAddress: "", tinNumber: "", @@ -87,6 +79,7 @@ export default function TabCompanyProfile({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -100,7 +93,7 @@ export default function TabCompanyProfile({ const base = { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -185,15 +178,11 @@ export default function TabCompanyProfile({ /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx index 7a2d9b931..d78add01b 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -14,24 +14,19 @@ import { Button, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -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 { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.contactPersonPhone); return { contactPersonName: profile.contactPersonName ?? "", - contactPersonPhone: phone.number, - contactPersonPhoneCountryCode: phone.code, + contactPersonPhone: profile.contactPersonPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: mutationFn: (data: FormData) => api.companies.updateProfile.call({ contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: {...register("contactPersonName")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index 80fb5f721..be8bef02c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -15,25 +15,20 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -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 { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.generalManagerPhone); return { generalManagerName: profile.generalManagerName ?? "", generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: phone.number, - generalManagerPhoneCountryCode: phone.code, + generalManagerPhone: profile.generalManagerPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } api.companies.updateProfile.call({ generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index f5f9a8210..6f951ff31 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -15,27 +15,22 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ poaName: z.string().optional(), poaEmail: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaLocation: z.string().optional(), poaAddress: z.string().optional(), }); type FormData = z.infer; -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 { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({ const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.poaPhone); return { poaName: profile.poaName ?? "", poaEmail: profile.poaEmail ?? "", - poaPhone: phone.number, - poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251", + poaPhone: profile.poaPhone ?? "", poaLocation: profile.poaLocation ?? "", poaAddress: profile.poaAddress ?? "", }; @@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, poaAddress: data.poaAddress || undefined, @@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({ /> - diff --git a/apps/edr-freight-web/portal/src/utils/phone.ts b/apps/edr-freight-web/portal/src/utils/phone.ts deleted file mode 100644 index ff75e4719..000000000 --- a/apps/edr-freight-web/portal/src/utils/phone.ts +++ /dev/null @@ -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}`; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59c0e1d63..a8a282151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + libphonenumber-js: + specifier: ^1.13.6 + version: 1.13.6 minio: specifier: 7.1.3 version: 7.1.3 @@ -349,6 +352,9 @@ importers: react-hot-toast: specifier: ^2.6.0 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: specifier: ^6.27.0 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: 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: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -6200,6 +6209,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.17: + resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -7946,6 +7958,17 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} 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: resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==} engines: {node: '>=0.10.0'} @@ -10490,6 +10513,12 @@ packages: '@types/react': 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: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -19678,6 +19707,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -19944,6 +19975,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + country-flag-icons@1.6.17: {} + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -22131,6 +22164,13 @@ snapshots: 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: dependencies: meow: 3.7.0 @@ -25075,6 +25115,16 @@ snapshots: optionalDependencies: '@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): dependencies: '@types/use-sync-external-store': 0.0.6