From 8039f70deb23f67c915e26ccd971035f2e5d8f10 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 26 May 2026 10:09:27 +0300 Subject: [PATCH 01/15] refactor(booking): Migrate new booking logic to react-query, deprecate mock service and draft IDs --- .../src/pages/bookings/NewBookingPage.tsx | 96 +++++-------------- .../pages/bookings/new-booking-form/schema.ts | 14 +-- .../new-booking-form/step1-contract-type.tsx | 20 +--- 3 files changed, 27 insertions(+), 103 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 49d233167..da777d491 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; -import { addBooking } from "./bookings.mock"; import { getCurrentCustomer } from "@/lib/currentCustomer"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; @@ -33,10 +33,18 @@ import { export default function NewBookingPage() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [step, setStep] = useState(1); const [renewalValidating, setRenewalValidating] = useState(false); const [renewalValid, setRenewalValid] = useState(null); - const [submitted, setSubmitted] = useState(false); + const createMutation = useMutation({ + mutationFn: (payload: CreateBookingPayload) => + api.bookings.create.call(payload), + onSuccess: (booking) => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + setTimeout(() => navigate(`/bookings/${booking.id}`), 2500); + }, + }); const form = useForm({ defaultValues: initialBookingFormValues, @@ -48,8 +56,6 @@ export default function NewBookingPage() { const destinationYard = form.watch("destinationYard"); const containers = form.watch("containers"); const previousContractRef = form.watch("previousContractRef"); - const contractId = - form.watch("draftContractId") || form.watch("previousContractRef"); const direction = useMemo( () => getRouteDirection(originYard, destinationYard), @@ -113,7 +119,7 @@ export default function NewBookingPage() { setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); } - function handleSubmit(data: BookingFormValues) { + const handleSubmit = form.handleSubmit((data) => { if (data.contractType === "renewal" && renewalValid !== true) { form.setError("previousContractRef", { type: "manual", @@ -124,15 +130,7 @@ export default function NewBookingPage() { } const me = getCurrentCustomer(); - const reference = - data.draftContractId || - data.previousContractRef || - `EDR-DRAFT-${Date.now()}`; - - const qtyCount = - data.cargoType === "container" - ? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0) - : 1; + const reference = data.previousContractRef; const totalWeight = data.cargoType === "container" @@ -142,48 +140,13 @@ export default function NewBookingPage() { ) : Number(data.cargoWeight || 0); - const description = - data.cargoType === "container" - ? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ") - : data.freightType === "bulk" - ? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}` - : `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`; - - const newBooking = { - id: Date.now(), - reference, - customerId: me.id, - customer: me.company, - cargoType: (data.cargoType === "container" - ? "Containerized" - : "Bulk") as any, - originStation: data.originYard, - destinationStation: data.destinationYard, - transportMode: (data.serviceType === "rail" - ? "Rail" - : "Multimodal") as any, - containerType: (data.cargoType === "container" && - data.containers[0]?.type === "40ft" - ? "40FT" - : "20FT") as any, - containerCount: qtyCount, - weightTons: totalWeight, - requestedDate: new Date().toISOString().slice(0, 10), - priority: (data.isHazardous ? "High" : "Normal") as any, - cargoDescription: description, - specialInstructions: data.notes || "Standard handling required", - status: "Pending" as any, - }; - - addBooking(newBooking); - - // Call API using api.bookings.create.call const apiPayload = { reference, customerId: String(me.id), scheduledDate: new Date().toISOString().slice(0, 10), totalAmount: 0, - contractType: data.contractType.toUpperCase(), + contractType: + data.contractType.toUpperCase() as CreateBookingPayload["contractType"], previousContractId: data.previousContractRef || undefined, serviceType: data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING", @@ -197,8 +160,8 @@ export default function NewBookingPage() { : undefined, equipmentReturn: data.equipmentReturn === "with_return" - ? "WITH_RETURN" - : "WITHOUT_RETURN", + ? ("WITH_RETURN" as const) + : ("WITHOUT_RETURN" as const), originStation: data.originYard, destinationStation: data.destinationYard, cargoTotalWeightVgm: totalWeight, @@ -220,31 +183,18 @@ export default function NewBookingPage() { ...(data.cargoType === "container" && data.containers.length > 0 ? { containers: data.containers.map((c) => ({ - type: c.type === "40ft" ? "40FT" as const : "20FT" as const, + type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const), qty: Number(c.qty || 1), vgm: Number(c.vgm || 0), })), } : {}), - }; + } satisfies CreateBookingPayload; - api.bookings.create - .call(apiPayload as CreateBookingPayload) - .then((created) => { - console.log("Successfully created booking via API:", created); - }) - .catch((err) => { - console.warn( - "API call failed (expected if API server is offline), falling back to mock storage:", - err, - ); - }); + createMutation.mutate(apiPayload); + }); - setSubmitted(true); - setTimeout(() => navigate("/bookings"), 2500); - } - - if (submitted) { + if (createMutation.isSuccess && createMutation.data) { return (
@@ -257,7 +207,7 @@ export default function NewBookingPage() { notified once approved.

- {contractId} + {createMutation.data.reference}

@@ -268,7 +218,7 @@ export default function NewBookingPage() {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 1549606ed..2a34eb843 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -151,7 +151,6 @@ export const bookingFormSchema = z .object({ contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string(), - draftContractId: z.string(), serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), firstMileEnabled: z.boolean(), pickUpAddress: z.string(), @@ -162,7 +161,7 @@ export const bookingFormSchema = z destinationYard: z.string(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), - freightType: z.enum(["bulk", "break_bulk", ""]).default(""), + freightType: z.enum(["bulk", "break_bulk"]), bulkCommodity: z.string(), bulkCommodityOther: z.string(), breakBulkType: z.string(), @@ -188,14 +187,6 @@ export const bookingFormSchema = z termsAccepted: z.boolean(), }) .superRefine((data, ctx) => { - if (data.contractType === "new" && !data.draftContractId.trim()) { - ctx.addIssue({ - code: "custom", - path: ["draftContractId"], - message: "A draft contract ID is required.", - }); - } - if (data.contractType === "renewal" && !data.previousContractRef.trim()) { ctx.addIssue({ code: "custom", @@ -360,7 +351,6 @@ export type BookingFormValues = z.infer; export const initialBookingFormValues: Partial = { previousContractRef: "", - draftContractId: "", firstMileEnabled: false, pickUpAddress: "", lastMileEnabled: false, @@ -383,7 +373,7 @@ export const initialBookingFormValues: Partial = { }; export const stepFields: Record> = { - 1: ["contractType", "previousContractRef", "draftContractId"], + 1: ["contractType", "previousContractRef"], 2: ["serviceType"], 3: [ "firstMileEnabled", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index 1343bdb2e..e8e045e5c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -1,7 +1,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { FileText, Loader2, RefreshCw } from "lucide-react"; -import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; -import { type BookingFormValues, genContractId } from "./schema"; +import { Button, Field, FieldLabel, Input } from "@edr/ui-common"; +import { type BookingFormValues } from "./schema"; import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared"; type BookingForm = UseFormReturn; @@ -18,9 +18,7 @@ export function Step1ContractType({ onValidate: () => void; }) { const contractType = form.watch("contractType"); - const draftContractId = form.watch("draftContractId"); const previousContractRef = form.watch("previousContractRef"); - const errors = form.formState.errors; return (
@@ -40,12 +38,6 @@ export function Step1ContractType({ onClick={() => { field.onChange("new"); form.clearErrors(["contractType", "previousContractRef"]); - if (!draftContractId) { - form.setValue("draftContractId", genContractId(), { - shouldDirty: true, - shouldValidate: true, - }); - } }} >
@@ -55,11 +47,6 @@ export function Step1ContractType({

Blank contract form. A draft ID is auto-generated.

- {field.value === "new" && draftContractId && ( -

- {draftContractId} -

- )}
- )} /> From 67d9d13591b18b9b1ea53b9f00dc2ced4cc4677b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 26 May 2026 10:10:47 +0300 Subject: [PATCH 02/15] chore: sync Ibooking --- packages/types/src/freight/index.ts | 45 ++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index b6d1e6dfa..48d3f87af 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -108,11 +108,54 @@ export interface IConsignment extends BaseEntity { export interface IBooking extends BaseEntity { reference: string; customerId: string; - trainId?: string; + trainId?: string | null; status: BookingStatus; scheduledDate: string; totalAmount: number; paymentStatus: PaymentStatus; + + contractType: "NEW" | "RENEWAL"; + previousContractId?: string | null; + serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING"; + + firstMileEnabled: boolean; + firstMilePickupAddress?: string | null; + lastMileEnabled: boolean; + lastMileDeliveryAddress?: string | null; + + equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; + originStation: string; + destinationStation: string; + cargoTotalWeightVgm: number; + + freightType: "BULK" | "BREAK_BULK"; + freightSubtype?: string | null; + + isHazardous: boolean; + isRefrigerated: boolean; + + tradeDirection: "IMPORT" | "EXPORT"; + paymentCurrency: string; + allowConsolidation: boolean; + consolidationPartnerId?: string | null; + + startDate?: string | null; + endDate?: string | null; + financialTerms?: string | null; + + containers?: Array<{ type: string; qty: number; vgm: number }> | null; + + versionNumber: number; + priorityScore: number; + + approvedByStaffId?: string | null; + approvedByStaffAt?: string | null; + signedByDirectorId?: string | null; + signedByDirectorAt?: string | null; + signedByCeoId?: string | null; + signedByCeoAt?: string | null; + + files?: Array<{ id: string; name: string; url: string; mimeType: string }>; } export interface IInvoice extends BaseEntity { From 6b2a34151190d376a06dd12ef67f500a7a1daaac Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:32:46 +0300 Subject: [PATCH 03/15] refactor(services): Consolidate user authentication logic and add customer API --- .../portal/src/services/account.ts | 92 ------------------ .../portal/src/services/api.ts | 95 +++++++++++++++++++ .../portal/src/services/auth.service.ts | 78 +++++++++++++++ apps/edr-freight-web/portal/src/types/auth.ts | 65 +++++++++++++ .../portal/src/types/createUser.ts | 10 -- .../src/types/generateVerificationCode.ts | 5 - 6 files changed, 238 insertions(+), 107 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/services/account.ts create mode 100644 apps/edr-freight-web/portal/src/services/auth.service.ts create mode 100644 apps/edr-freight-web/portal/src/types/auth.ts delete mode 100644 apps/edr-freight-web/portal/src/types/createUser.ts delete mode 100644 apps/edr-freight-web/portal/src/types/generateVerificationCode.ts diff --git a/apps/edr-freight-web/portal/src/services/account.ts b/apps/edr-freight-web/portal/src/services/account.ts deleted file mode 100644 index 6e97e2dd0..000000000 --- a/apps/edr-freight-web/portal/src/services/account.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { URL_CONSTANTS } from "@/constants/URLS"; -import { CreateUserPayload } from "@/types/createUser"; -import { VerificationCodePayload } from "@/types/generateVerificationCode"; -import { UserTypeRequest } from "@/types/userTypeRequest"; -import { client } from "@/utils/api"; -import { ApiResponse } from "@edr/types"; -import { GenerateVerifcationCodePayload } from "node_modules/@tria-plc/iamui-common/dist/types/shared/services/authService"; - -// ----------------------------------------------------------------------------- -// API -// ----------------------------------------------------------------------------- - -export const createUser = async ( - body: CreateUserPayload -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.USERS.SIGN_UP, - body - ); - - return res.data; -}; - -export const getMyInfo = async () => { - const res = - await client.get< - ApiResponse - >( - URL_CONSTANTS.USERS.ME - ); - - return res.data; -}; - -export const generateVerificationCode = async ( - body: VerificationCodePayload -) => { - const res = - await client.patch< - ApiResponse - >( - URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE, - body - ); - - return res.data.data; -}; - -export const setPassword = async ( - body: any -) => { - const res = - await client.patch< - ApiResponse - >( - URL_CONSTANTS.USERS.SET_PASSWORD, - body - ); - - return res.data.data; -}; - -export const createOTP = async ( - body: any -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.OTP.SEND, - body - ); - - return res.data; -}; - -export const verifyOTP = async ( - body: any -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.OTP.VERIFY, - body - ); - - return res.data; -}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ba2b227c6..d05807090 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -13,6 +13,8 @@ import { consignmentsService } from "./consignments.service"; import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; +import { authService } from "./auth.service"; +import { customersService } from "./customers.service"; import { CreateDropdownOptionDto, CreateDropdownSettingDto, @@ -21,12 +23,105 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; +import { + CreateCustomerDto, + Customer, + UpdateCustomerDto, +} from "@/types/customers"; +import type { + AuthUser, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; // --------------------------------------------------------------------------- // API definition // --------------------------------------------------------------------------- export const api = { + auth: { + login: endpoint( + "auth", + "login", + authService.login, + ), + createUser: endpoint( + "auth", + "createUser", + authService.createUser, + ), + getMyInfo: endpoint( + "auth", + "getMyInfo", + authService.getMyInfo, + ), + generateVerificationCode: endpoint( + "auth", + "generateVerificationCode", + authService.generateVerificationCode, + ), + setPassword: endpoint( + "auth", + "setPassword", + authService.setPassword, + ), + sendOTP: endpoint( + "auth", + "sendOTP", + authService.sendOTP, + ), + verifyOTP: endpoint( + "auth", + "verifyOTP", + authService.verifyOTP, + ), + logout: endpoint( + "auth", + "logout", + authService.logout, + ), + }, + + customers: { + list: endpoint( + "customers", + "list", + customersService.list, + ), + + get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) => + customersService.getById(id), + ), + + create: endpoint( + "customers", + "create", + customersService.create, + ), + + update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>( + "customers", + "update", + ({ id, dto }) => customersService.update(id, dto), + ), + + remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) => + customersService.remove(id), + ), + + getByUserId: endpoint<{ id: string }, Customer>( + "customers", + "getByUserId", + ({ id }) => customersService.getByUserId(id), + ), + }, + bookings: { list: endpoint>( "bookings", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts new file mode 100644 index 000000000..bb162fbea --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -0,0 +1,78 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "@/utils/api"; +import { ApiResponse } from "@edr/types"; +import type { + AuthUser, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; + +export const authService = { + login: async (body: LoginPayload) => { + const res = await client.post>( + URL_CONSTANTS.AUTH.LOGIN, + body, + ); + return res.data.data; + }, + + createUser: async (body: SignupPayload) => { + const res = await client.post>( + URL_CONSTANTS.USERS.SIGN_UP, + body, + ); + return res.data.data; + }, + + getMyInfo: async () => { + const res = await client.get>( + URL_CONSTANTS.USERS.ME, + ); + return res.data.data; + }, + + generateVerificationCode: async (body: GenerateVerificationCodePayload) => { + const res = await client.patch>( + URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE, + body, + ); + return res.data.data; + }, + + setPassword: async (body: SetPasswordPayload) => { + const res = await client.patch>( + URL_CONSTANTS.USERS.SET_PASSWORD, + body, + ); + return res.data.data; + }, + + sendOTP: async (body: OtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.OTP.SEND, + body, + ); + return res.data.data; + }, + + verifyOTP: async (body: OtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.OTP.VERIFY, + body, + ); + return res.data.data; + }, + + logout: async () => { + const res = await client.patch>( + URL_CONSTANTS.AUTH.LOGOUT, + ); + return res.data.data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts new file mode 100644 index 000000000..08318417d --- /dev/null +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -0,0 +1,65 @@ +export interface AuthUser { + id: string; + name: { am: string; en: string }; + email: string; + roles: string[]; + status: string; + employee: any[]; + userType: string; + username: string; + permissions: string[]; + phoneNumber: string; + sharepointId: string | null; + hasSetPassword: boolean; + hasFinishedRegistration: boolean; + hasFinishedDMSOnboarding: boolean; +} + +export interface SignupPayload { + email: string; + username: string; + phoneNumber: string; + userType: string; + name: { en: string; am: string }; +} + +export interface SignupResponse { + token: string; + refreshToken: string; + otp: string; + userId: string; +} + +export interface OtpPayload { + phone: string; + otp: string; +} + +export interface OtpResponse { + success: boolean; + message: string; +} + +export interface SetPasswordPayload { + newPassword: string; + confirmPassword: string; + userId: string; + email: string; + verificationCode: string; +} + +export interface GenerateVerificationCodePayload { + email: string; + phoneNumber: string; + type: string; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export interface LoginResponse { + token: string; + refreshToken: string; +} diff --git a/apps/edr-freight-web/portal/src/types/createUser.ts b/apps/edr-freight-web/portal/src/types/createUser.ts deleted file mode 100644 index 53bd6c8e9..000000000 --- a/apps/edr-freight-web/portal/src/types/createUser.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type CreateUserPayload = { - email: string; - username: string; - phoneNumber: string; - userType: string; - name: { - en: string; - am?: string; - }; -}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts b/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts deleted file mode 100644 index 24f0e206d..000000000 --- a/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type VerificationCodePayload = { - email: string; - phoneNumber: string; - type: string; -}; \ No newline at end of file From 06c976d6d9251fe25baf0894fba6afd18715db22 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:35:31 +0300 Subject: [PATCH 04/15] chore: urls --- .../portal/src/constants/URLS.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f22b31e39..6bce94b12 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -1,21 +1,25 @@ export const URL_CONSTANTS = { AUTH: { - LOGIN: "/auth/login", - REGISTER: "/auth/register", + LOGIN: "/api/auth/login", + REGISTER: "/api/auth/register", REFRESH_TOKEN: "/auth/refresh-token", - LOGOUT: "/auth/logout", + LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, USERS: { - SIGN_UP: "/api/auth/signup", - GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code", BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, + SIGN_UP: "/api/auth/signup", SET_PASSWORD: "/api/auth/set-password", - ME: "/api/auth/me" + ME: "/api/auth/me", + GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", }, + OTP: { + SEND: "/api/otp/send", + VERIFY: "/api/otp/verify", + }, ROLES: { BASE: "/roles", BY_ID: (id: string | number) => `/roles/${id}`, @@ -68,11 +72,11 @@ export const URL_CONSTANTS = { BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, - + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, - BY_USER_ID: (id: string) => `/api/customers/user/${id}` + BY_USER_ID: (id: string) => `/api/customers/user/${id}`, }, BOOKINGS: { @@ -81,9 +85,4 @@ export const URL_CONSTANTS = { CANCEL: (id: string | number) => `/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, }, - - OTP: { - SEND: "/api/otp/send", - VERIFY: "/api/otp/verify", - } -}; \ No newline at end of file +}; From 6dac547a74f84a7f166ec052fcf8af092cce5268 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:36:43 +0300 Subject: [PATCH 05/15] feat(utils): Introduce Result type and API error extraction utility --- .../portal/src/utils/result.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/utils/result.ts diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts new file mode 100644 index 000000000..3e9627445 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -0,0 +1,29 @@ +export type Result = + | { success: true; data: T } + | { success: false; error: E }; + +export type ApiError = { + code: string; + message: string; + statusCode?: number; +}; + +export function extractApiError(err: unknown): ApiError { + if (err && typeof err === "object") { + const obj = err as Record; + const response = obj.response as Record | undefined; + if (response) { + const statusCode = response.status as number | undefined; + const data = response.data as Record | undefined; + return { + code: (data?.error as string) || (data?.message as string) || "api_error", + message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + statusCode, + }; + } + if (obj.message && typeof obj.message === "string") { + return { code: "client_error", message: obj.message }; + } + } + return { code: "unknown_error", message: "An unexpected error occurred" }; +} From 64356a20e606efba97b649c7ebf4dd556c5ee4db Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:37:03 +0300 Subject: [PATCH 06/15] feat(frieght): setup useAuth --- .../portal/src/hooks/useAuth.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/hooks/useAuth.ts diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts new file mode 100644 index 000000000..2bd7b6383 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -0,0 +1,141 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import type { + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; +import type { Result } from "@/utils/result"; +import { extractApiError } from "@/utils/result"; + +const useAuth = () => { + const queryClient = useQueryClient(); + + const authQuery = useQuery(api.auth.getMyInfo.queryOptions()); + + const customerQuery = useQuery( + api.customers.getByUserId.queryOptions({ + input: { id: authQuery.data?.id ?? "" }, + enabled: !!authQuery.data?.id, + retry: false, + }), + ); + + const isPending = authQuery.isPending; + + const login = async ( + payload: LoginPayload, + ): Promise> => { + try { + const res = await api.auth.login.call(payload); + await queryClient.invalidateQueries({ + queryKey: api.auth.getMyInfo.queryKey(), + }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const signup = async ( + payload: SignupPayload, + ): Promise> => { + try { + const res = await api.auth.createUser.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const setPassword = async ( + payload: SetPasswordPayload, + ): Promise> => { + try { + await api.auth.setPassword.call(payload); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const verifyOTP = async ( + payload: OtpPayload, + ): Promise> => { + try { + const res = await api.auth.verifyOTP.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const sendOTP = async ( + payload: OtpPayload, + ): Promise> => { + try { + const res = await api.auth.sendOTP.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const generateVerificationCode = async ( + payload: GenerateVerificationCodePayload, + ): Promise> => { + try { + const res = await api.auth.generateVerificationCode.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const logout = async () => { + try { + await api.auth.logout.call(); + } catch { + // proceed with client-side cleanup even if server call fails + } + [ + "auth-token", + "refresh-token", + "auth-user", + "current-position-id", + "selected-position-id", + ].forEach((name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }); + localStorage.clear(); + queryClient.clear(); + window.location.href = "/auth"; + }; + + const invalidate = async () => { + await Promise.all([authQuery.refetch(), customerQuery.refetch()]); + }; + + return { + isPending, + user: authQuery.data ?? null, + customer: customerQuery.data ?? null, + login, + signup, + setPassword, + verifyOTP, + sendOTP, + generateVerificationCode, + logout, + invalidate, + authQuery, + customerQuery, + }; +}; + +export default useAuth; From 53e8b40123a0f55b7c0fc18c47789e4789b0fcab Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 11:38:22 +0300 Subject: [PATCH 07/15] refactor(auth): Remove IAM library, implement custom cookie and local storage auth --- .../portal/src/constants/URLS.ts | 2 +- .../portal/src/hooks/useAuth.ts | 90 ++++++++---- apps/edr-freight-web/portal/src/main.tsx | 43 +----- .../portal/src/services/auth.service.ts | 12 ++ apps/edr-freight-web/portal/src/utils/api.ts | 129 +++++++++++++++--- 5 files changed, 190 insertions(+), 86 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6bce94b12..84db5e2c2 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -2,7 +2,7 @@ export const URL_CONSTANTS = { AUTH: { LOGIN: "/api/auth/login", REGISTER: "/api/auth/register", - REFRESH_TOKEN: "/auth/refresh-token", + REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 2bd7b6383..5b626721c 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,22 +1,37 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/services/api"; import type { - GenerateVerificationCodePayload, LoginPayload, LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, SignupPayload, SignupResponse, + OtpResponse, } from "@/types/auth"; import type { Result } from "@/utils/result"; import { extractApiError } from "@/utils/result"; +function setCookie(name: string, value: string, days: number) { + const expires = new Date(); + expires.setDate(expires.getDate() + days); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; +} + +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + const useAuth = () => { const queryClient = useQueryClient(); - const authQuery = useQuery(api.auth.getMyInfo.queryOptions()); + const authQuery = useQuery( + api.auth.getMyInfo.queryOptions({ + enabled: !!getCookie("auth-token"), + retry: false, + }), + ); const customerQuery = useQuery( api.customers.getByUserId.queryOptions({ @@ -26,16 +41,17 @@ const useAuth = () => { }), ); - const isPending = authQuery.isPending; + const hasToken = !!getCookie("auth-token"); + const isPending = authQuery.isPending && hasToken; const login = async ( payload: LoginPayload, ): Promise> => { try { const res = await api.auth.login.call(payload); - await queryClient.invalidateQueries({ - queryKey: api.auth.getMyInfo.queryKey(), - }); + setCookie("auth-token", res.token, 7); + setCookie("refresh-token", res.refreshToken, 7); + await authQuery.refetch(); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -47,39 +63,59 @@ const useAuth = () => { ): Promise> => { try { const res = await api.auth.createUser.call(payload); + localStorage.setItem("auth-token", `auth-token=${res.token}; path=/`); + localStorage.setItem("userId", res.userId); + const otpCode = res.otp?.split(" ")?.[6] ?? ""; + localStorage.setItem("otp", otpCode); + localStorage.setItem("otp-phone", payload.phoneNumber); + localStorage.setItem("otp-email", payload.email); + api.auth.sendOTP + .call({ phone: payload.phoneNumber, otp: otpCode }) + .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const setPassword = async ( - payload: SetPasswordPayload, - ): Promise> => { + const setPassword = async (data: { + newPassword: string; + confirmPassword: string; + }): Promise> => { try { - await api.auth.setPassword.call(payload); + const userId = localStorage.getItem("userId") ?? ""; + const email = localStorage.getItem("otp-email") ?? ""; + const verificationCode = localStorage.getItem("otp") ?? ""; + await api.auth.setPassword.call({ + newPassword: data.newPassword, + confirmPassword: data.confirmPassword, + userId, + email, + verificationCode, + }); + ["userId", "otp", "otp-phone", "otp-email"].forEach((k) => + localStorage.removeItem(k), + ); return { success: true, data: undefined }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const verifyOTP = async ( - payload: OtpPayload, - ): Promise> => { + const verifyOTP = async (otp: string): Promise> => { try { - const res = await api.auth.verifyOTP.call(payload); + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.verifyOTP.call({ phone, otp }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const sendOTP = async ( - payload: OtpPayload, - ): Promise> => { + const sendOTP = async (otp: string): Promise> => { try { - const res = await api.auth.sendOTP.call(payload); + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.sendOTP.call({ phone, otp }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -87,10 +123,16 @@ const useAuth = () => { }; const generateVerificationCode = async ( - payload: GenerateVerificationCodePayload, + type: string, ): Promise> => { try { - const res = await api.auth.generateVerificationCode.call(payload); + const email = localStorage.getItem("otp-email") ?? ""; + const phoneNumber = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.generateVerificationCode.call({ + email, + phoneNumber, + type, + }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -114,7 +156,7 @@ const useAuth = () => { }); localStorage.clear(); queryClient.clear(); - window.location.href = "/auth"; + window.location.href = "/login"; }; const invalidate = async () => { diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 45014a8d5..8c4fbeb96 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,18 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import "@tria-plc/iamui-common/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; import App from "./App"; -import { - AuthProvider, - configureIam, - UserProvider, - axiosInstance, -} from "@tria-plc/iamui-common"; // Purge cookies that were stored as the literal string "undefined" before the // envelope interceptor fix. Without this, stale sessions would keep sending @@ -29,36 +22,6 @@ import { }); const queryClient = new QueryClient(); -window.__IAM_CONFIG__ = { - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, - postLoginPath: "/", -}; - -// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the -// freight API's ResponseTransformInterceptor adds to every response, so that -// iamui-common can read response.data.token / response.data fields as expected. -axiosInstance.interceptors.response.use((response) => { - if ( - response.data && - typeof response.data === "object" && - "success" in response.data && - "data" in response.data - ) { - response.data = response.data.data; - } - return response; -}); -window.__USER_MANAGEMENT_BRANDING__ = { - organizationName: "EDR Platform", - appName: "EDR Portal", - moduleBasePath: "/user-management", - backToAppPath: "/", - backToAppLabel: "Back to dashboard", -}; - -configureIam({ - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, -}); const rootElement = document.getElementById("root"); @@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render( - - - - - + , diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index bb162fbea..856d417cc 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -69,6 +69,18 @@ export const authService = { return res.data.data; }, + refreshToken: async () => { + const refreshTokenCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("refresh-token=")) + ?.split("=")[1]; + const res = await client.post>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken: refreshTokenCookie }, + ); + return res.data.data; + }, + logout: async () => { const res = await client.patch>( URL_CONSTANTS.AUTH.LOGOUT, diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 5fe426a0c..d152e6aad 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -2,38 +2,129 @@ import { UseQueryOptions, QueryObserverOptions, } from "@tanstack/react-query"; -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { URL_CONSTANTS } from "@/constants/URLS"; -// --------------------------------------------------------------------------- -// Axios client -// --------------------------------------------------------------------------- - -export const client = axios.create({ +const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, }); +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + +function setCookie(name: string, value: string, days: number) { + const expires = new Date(); + expires.setDate(expires.getDate() + days); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; +} + +function clearAuthCookies() { + ["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach( + (name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }, + ); +} + // Attach auth token to every request client.interceptors.request.use((config) => { - // TODO: replace with secure storage (cookie/localStorage/auth provider) - const token = document.cookie - .split("; ") - .find((row) => row.startsWith("auth-token=")) - ?.split("=")[1]; - + const token = getCookie("auth-token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } - return config; }); -// Handle auth errors globally +// Token refresh state +let isRefreshing = false; +let failedQueue: { + resolve: (token: string) => void; + reject: (error: unknown) => void; +}[] = []; + +function processQueue(error: unknown, token?: string) { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token!); + } + }); + failedQueue = []; +} + +// Handle auth errors globally with token refresh client.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - window.location.href = "/auth"; + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // Don't intercept if: + // - no response (network error) + // - status is not 401 + // - already retried + // - it's the refresh endpoint itself + if ( + !error.response || + error.response.status !== 401 || + originalRequest._retry || + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + ) { + return Promise.reject(error); } - return Promise.reject(error); - } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = getCookie("refresh-token"); + + if (!refreshToken) { + isRefreshing = false; + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(error); + } + + try { + const { data } = await client.post<{ data: { token: string; refreshToken: string } }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + const { token, refreshToken: newRefreshToken } = data.data; + setCookie("auth-token", token, 7); + setCookie("refresh-token", newRefreshToken, 7); + originalRequest.headers.Authorization = `Bearer ${token}`; + processQueue(null, token); + return client(originalRequest); + } catch (refreshError) { + processQueue(refreshError, undefined); + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + }, ); + +export { client }; +export type { UseQueryOptions, QueryObserverOptions }; From b30a04edce5f5ee14444417bf62e6ef29fbc36ad Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 11:41:23 +0300 Subject: [PATCH 08/15] refactor(auth): Transition main app to custom authentication system and local useAuth hook --- apps/edr-freight-web/portal/src/App.tsx | 44 +- .../portal/src/components/auth/AuthLayout.tsx | 86 +++ .../portal/src/pages/accounts/LoginPage.tsx | 163 +++++ .../src/pages/accounts/SetPasswordPage.tsx | 519 ++++---------- .../portal/src/pages/accounts/SignupPage.tsx | 650 +++++------------- .../pages/accounts/VerificationOtpPage.tsx | 573 ++++----------- 6 files changed, 696 insertions(+), 1339 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 968f0925d..a546a43d8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -19,6 +19,7 @@ import { UserCircle, FileUp, MapPinned, + Loader2, } from "lucide-react"; import BookingsPage from "./pages/bookings/BookingsPage"; @@ -31,12 +32,7 @@ import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; import TrainsPage from "./pages/trains/TrainsPage"; import DashboardPage from "./pages/dashboard/DashboardPage"; -import { - IamLoginPage, - LoadingScreen, - useAuth, - useAuthUser, -} from "@tria-plc/iamui-common"; +import useAuth from "./hooks/useAuth"; import CustomersPage from "./pages/customers/CustomersPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import NewCustomerPage from "./pages/customers/NewCustomerPage"; @@ -48,6 +44,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; +import LoginPage from "./pages/accounts/LoginPage"; import Station from "./components/stations/Station"; const sidebarItems: SidebarItem[] = [ @@ -72,22 +69,26 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, loading } = useAuth(); - const { logout } = useAuthUser(); + const { user, isPending, logout } = useAuth(); - if (loading) { - return ; + console.log({ user, isPending }); + if (isPending) { + return ( +
+ +
+ ); } - if (user) { + if (!user) { return ( } /> + } /> } /> } /> } /> - } /> - {/* } /> */} + } /> ); } @@ -95,21 +96,6 @@ const App = () => { const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; - const handleLogout = () => { - logout(); - [ - "auth-token", - "refresh-token", - "auth-user", - "current-position-id", - "selected-position-id", - ].forEach((name) => { - document.cookie = `${name}=; Max-Age=0; path=/`; - }); - localStorage.clear(); - window.location.replace("/auth"); - }; - return ( { enableThemeToggle userName={displayName} userEmail={userEmail} - onLogout={handleLogout} + onLogout={logout} > } /> diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx new file mode 100644 index 000000000..c4b908a09 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { ShieldCheck, Train } from "lucide-react"; + +export interface AuthLayoutProps { + children: ReactNode; + left: { + badge: string; + title: string; + description: string; + features: string[]; + stats: { + label: string; + value: string; + footer: string; + progress: string; + }; + }; +} + +export default function AuthLayout({ children, left }: AuthLayoutProps) { + return ( +
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

Railway Logistics Platform

+
+
+
+
+ {left.badge} +
+

{left.title}

+

{left.description}

+
+
+ {left.features.map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+
+
+
+

{left.stats.label}

+

{left.stats.value}

+
+
{left.stats.footer}
+
+
+
+
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

Railway Logistics Platform

+
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx new file mode 100644 index 000000000..0af8d0af2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; + +type LoginMethod = "email" | "phone"; + +export default function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + const [method, setMethod] = useState("email"); + const [identifier, setIdentifier] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const result = await login({ email: identifier, password }); + if (result.success) { + navigate("/"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+ +
+

Welcome back

+

+ Enter your credentials to access your portal +

+
+ + +
+ + +
+ +
+ + setIdentifier(e.target.value)} + required + disabled={loading} + className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" + /> +
+ +
+
+ + +
+ setPassword(e.target.value)} + required + disabled={loading} + className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ Don't have an account?{" "} + +

+ +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 83a9cd78d..268cf836e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,418 +1,153 @@ -import { setPassword } from "@/services/account"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; -import { - ArrowRight, - LockKeyhole, - ShieldCheck, - Train, - Eye, - EyeOff, -} from "lucide-react"; import { useState } from "react"; -import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; const passwordSchema = z .object({ - password: z - .string() - .min( - 8, - "Password must be at least 8 characters" - ), - - confirmPassword: z - .string() - .min( - 8, - "Confirm password is required" - ), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(8, "Confirm password is required"), }) - .refine( - (data) => - data.password === - data.confirmPassword, - { - message: - "Passwords do not match", - path: ["confirmPassword"], - } - ); + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); -type FormData = z.infer< - typeof passwordSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SetPasswordPage() { - const [ - showPassword, - setShowPassword, - ] = useState(false); - - const [ - showConfirmPassword, - setShowConfirmPassword, - ] = useState(false); + const navigate = useNavigate(); + const { setPassword } = useAuth(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(passwordSchema), - - defaultValues: { - password: "", - confirmPassword: "", - }, + resolver: zodResolver(passwordSchema), + defaultValues: { password: "", confirmPassword: "" }, }); - const naviagte = useNavigate(); - - // --------------------------------------------------------------------------- - // Mutation - // --------------------------------------------------------------------------- - - const setPasswordMutation = - useMutation({ - mutationFn: async ( - data: FormData - ) => setPassword({ - newPassword: data?.password, - confirmPassword: data?.confirmPassword, - userId: localStorage.getItem("userId"), - email: localStorage.getItem("otp-email"), - verificationCode: localStorage.getItem("otp"), - }), - - onSuccess: () => { - naviagte("/auth"); - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - await setPasswordMutation.mutateAsync( - data - ); - } catch (err) { - console.error(err); + const result = await setPassword({ + newPassword: data.password, + confirmPassword: data.confirmPassword, + }); + if (result.success) { + navigate("/auth"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Account Security -
- -

- Set your secure - password -

- -

- Create a strong - password to secure - your EDR Freight - account and protect - railway logistics - operations and shipment - data. -

-
- - {/* Features */} -
- {[ - "Enterprise-grade security", - "Protected account access", - "Secure freight operations", - "Advanced authentication system", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Security Protection -

- -

- 256-bit -

-
- -
- Encrypted -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Card */} -
- {/* Header */} -
-
- -
- -

- Set Password -

- -

- Create a secure - password for your - EDR Freight account. -

-
- - {/* Success */} - {setPasswordMutation.isSuccess && ( -
- Password updated - successfully. -
- )} - - {/* Error */} - {setPasswordMutation.isError && ( -
- Failed to set - password. Please try - again. -
- )} - - {/* Form */} -
- {/* Password */} -
- - -
- - - -
- - {errors.password && ( -

- { - errors.password - .message - } -

- )} -
- - {/* Confirm Password */} -
- - -
- - - -
- - {errors.confirmPassword && ( -

- { - errors - .confirmPassword - .message - } -

- )} -
- - {/* Submit */} - -
-
-
+ +
+
+
+

Set Password

+

+ Create a secure password for your EDR Freight account. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + +
+ ); -} \ No newline at end of file +} 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 2b9b7f135..05affbbe8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,534 +1,194 @@ -import { userType } from "@/enums/userType"; -import { createOTP, createUser } from "@/services/account"; - -import { CreateUserPayload } from "@/types/createUser"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - -import { - ArrowRight, - ShieldCheck, - Train, - UserPlus, -} from "lucide-react"; - -import { useForm } from "react-hook-form"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, UserPlus } from "lucide-react"; +import { userType } from "@/enums/userType"; +import useAuth from "@/hooks/useAuth"; +import type { SignupPayload } from "@/types/auth"; +import AuthLayout from "@/components/auth/AuthLayout"; const userSchema = z.object({ - email: z - .string() - .email("Invalid email address"), - - username: z - .string() - .min( - 3, - "Username must be at least 3 characters" - ), - - countryCode: z - .string() - .min( - 1, - "Country code is required" - ), - - phone: z - .string() - .min( - 9, - "Phone number is too short" - ) - .max( - 9, - "Phone number is too long" - ), - + email: z.string().email("Invalid email address"), + username: z.string().min(3, "Username must be at least 3 characters"), + countryCode: z.string().min(1, "Country code is required"), + phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"), userType: z.string(), - name: z.object({ - en: z - .string() - .min(2, "Name is required"), - + en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), }); -type FormData = z.infer< - typeof userSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SignupPage() { const navigate = useNavigate(); + const { signup } = useAuth(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(userSchema), - + resolver: zodResolver(userSchema), defaultValues: { email: "", username: "", countryCode: "+251", phone: "", - userType: - userType.individual, - - name: { - en: "", - am: "", - }, + userType: userType.individual, + name: { en: "", am: "" }, }, }); - // --------------------------------------------------------------------------- - // Create User Mutation - // --------------------------------------------------------------------------- - - const createUserMutation = - useMutation({ - mutationFn: ( - user: CreateUserPayload - ) => createUser(user), - - onSuccess: () => { - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - const normalizedPhone = - data.phone.startsWith( - "0" - ) - ? data.phone.slice(1) - : data.phone; - - const fullPhoneNumber = `${data.countryCode - }${normalizedPhone}`; - - const payload: CreateUserPayload = - { + const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone; + const payload: SignupPayload = { email: data.email, - - username: - data.username, - - phoneNumber: - fullPhoneNumber, - - userType: - data.userType, - - name: { - en: data.name.en, - am: - data.name.am || - "", - }, + username: data.username, + phoneNumber: `${data.countryCode}${normalizedPhone}`, + userType: data.userType, + name: { en: data.name.en, am: data.name.am ?? "" }, }; - - const res = - await createUserMutation.mutateAsync( - payload - ); - - if (res?.success) { - // save auth token - // document.cookie = `auth-token=${res.data?.token}; path=/`; - localStorage.setItem( - "auth-token", - `auth-token=${res.data?.token}; path=/` - ); - localStorage.setItem( - "userId",res.data?.userId - ); - localStorage.setItem( - "otp",res.data?.otp?.split(" ")?.[6] - ); - createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] }) - // save phone for otp page - localStorage.setItem( - "otp-phone", - payload.phoneNumber - ); - // save phone for set password page - - localStorage.setItem( - "otp-email", - payload.email - ); - // navigate otp page + const result = await signup(payload); + if (result.success) { navigate("/otp"); + } else { + setError(result.error.message); } - } catch (err) { - console.error(err); + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Smart Freight - Operations -
- -

- Create your freight - operations account -

- -

- Join EDR Freight to - manage shipments, - monitor railway - operations, track - consignments, and - streamline logistics - workflows across - Ethiopia and - Djibouti. -

-
- - {/* Features */} -
- {[ - "Real-time shipment tracking", - "Secure logistics management", - "Enterprise-grade operations", - "Multi-corridor freight monitoring", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Active Corridors -

- -

- 24+ -

-
- -
- Operational -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Form Card */} -
- {/* Header */} -
-
- -
- -

- Create Account -

- -

- Register to access - EDR Freight - services and railway - logistics operations. -

-
- - {/* Success */} - {createUserMutation.isSuccess && ( -
- Account created - successfully. -
- )} - - {/* Error */} - {createUserMutation.isError && ( -
- Failed to create - account. Please try - again. -
- )} - - {/* Form */} -
- {/* Full Name */} -
- - - - - {errors.name?.en && ( -

- { - errors.name.en - .message - } -

- )} -
- - {/* Username */} -
- - - - - {errors.username && ( -

- { - errors.username - .message - } -

- )} -
- - {/* Email */} -
- - - - - {errors.email && ( -

- { - errors.email - .message - } -

- )} -
- - {/* Phone */} -
- - -
- - - -
- - {(errors.countryCode || - errors.phone) && ( -

- {errors - .countryCode - ?.message || - errors.phone - ?.message} -

- )} -
- - {/* Submit */} - - - {/* Footer */} -

- Already have an - account? - - -

-
-
-
+ +
+
+
+

Create Account

+

+ Register to access EDR Freight services and railway logistics operations. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + + {errors.name?.en &&

{errors.name.en.message}

} +
+ +
+ + + {errors.username &&

{errors.username.message}

} +
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ +
+ + +
+ {(errors.countryCode || errors.phone) && ( +

+ {errors.countryCode?.message || errors.phone?.message} +

+ )} +
+ + + +

+ Already have an account? + +

+
+ ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index 091406df3..183980e4f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -1,70 +1,28 @@ -import { verificationCodeType } from "@/enums/verificationCodeType"; - -import { - generateVerificationCode, - verifyOTP, -} from "@/services/account"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - -import { - ArrowRight, - ShieldCheck, - Train, - MailCheck, - RotateCw, -} from "lucide-react"; - import { useForm } from "react-hook-form"; - +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, MailCheck, RotateCw } from "lucide-react"; +import { verificationCodeType } from "@/enums/verificationCodeType"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; const otpSchema = z.object({ - code: z - .string() - .regex( - /^\d{6}$/, - "OTP must be exactly 6 digits" - ), + code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), }); -type FormData = z.infer< - typeof otpSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function VerificationOtpPage() { - const navigate = - useNavigate(); + const navigate = useNavigate(); + const { verifyOTP, generateVerificationCode } = useAuth(); + const [verifying, setVerifying] = useState(false); + const [resending, setResending] = useState(false); + const [error, setError] = useState(null); + const [resentMessage, setResentMessage] = useState(null); - // --------------------------------------------------------------------------- - // Local Storage Data - // --------------------------------------------------------------------------- - - const phone = - localStorage.getItem( - "otp-phone" - ) || ""; - - const email = - localStorage.getItem( - "otp-email" - ) || ""; - - // --------------------------------------------------------------------------- - // Form - // --------------------------------------------------------------------------- + const phone = localStorage.getItem("otp-phone") || ""; const { register, @@ -72,383 +30,152 @@ export default function VerificationOtpPage() { formState: { errors }, watch, } = useForm({ - resolver: - zodResolver(otpSchema), - - defaultValues: { - code: "", - }, + resolver: zodResolver(otpSchema), + defaultValues: { code: "" }, }); - const otpValue = - watch("code"); + const otpValue = watch("code"); - // --------------------------------------------------------------------------- - // Verify Mutation - // --------------------------------------------------------------------------- - - const verifyMutation = - useMutation({ - mutationFn: async ( - data: { - phone: string; - otp: string; - } - ) => verifyOTP(data), - - onSuccess: () => { - navigate( - "/set-password" - ); - }, - }); - - // --------------------------------------------------------------------------- - // Resend Mutation - // --------------------------------------------------------------------------- - - const resendMutation = - useMutation({ - mutationFn: async () => { - return generateVerificationCode( - { - email, - phoneNumber: - phone, - - type: - verificationCodeType.setPassword, - } - ); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setVerifying(true); try { - await verifyMutation.mutateAsync( - { - phone, - otp: data.code, - } - ); - } catch (err) { - console.error(err); + const result = await verifyOTP(data.code); + if (result.success) { + navigate("/set-password"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setVerifying(false); } }; - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- + const handleResend = async () => { + setResentMessage(null); + setResending(true); + try { + const result = await generateVerificationCode(verificationCodeType.setPassword); + if (result.success) { + setResentMessage("New OTP code sent successfully."); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setResending(false); + } + }; - const maskedPhone = - phone.length > 4 - ? `${phone.slice( - 0, - 7 - )}******` - : phone; - - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- + const maskedPhone = phone.length > 4 ? `${phone.slice(0, 7)}******` : phone; return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Secure - Verification -
- -

- Verify your - account securely -

- -

- Enter the - verification code - sent to your phone - number to continue - using EDR Freight - logistics services. -

-
- - {/* Features */} -
- {[ - "Secure OTP verification", - "Protected account access", - "Fast identity confirmation", - "Enterprise-grade security", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Footer Stats */} -
-
-
-

- Verification - Security -

- -

- 99.9% -

-
- -
- Protected -
-
- -
-
-
-
+ +
+
+
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* OTP Card */} -
- {/* Header */} -
-
- -
- -

- OTP Verification -

- -

- Enter the - 6-digit code sent - to: -

- -
-

- {maskedPhone} -

-
-
- - {/* Success */} - {verifyMutation.isSuccess && ( -
- Verification - successful. -
- )} - - {/* Error */} - {verifyMutation.isError && ( -
- Invalid OTP - code. Please try - again. -
- )} - - {/* Resend Success */} - {resendMutation.isSuccess && ( -
- New OTP code sent - successfully. -
- )} - - {/* Form */} -
- {/* OTP */} -
- - - - -
- {errors.code ? ( -

- { - errors.code - .message - } -

- ) : ( -

- Enter the OTP - sent to your - phone -

- )} - - - { - otpValue.length - } - /6 - -
-
- - {/* Verify Button */} - - - {/* Resend */} - - - {/* Footer */} -

- Didn’t receive - the code? - - -

-
-
-
+

OTP Verification

+

Enter the 6-digit code sent to:

+
+

{maskedPhone}

-
+ + {error && ( +
+ {error} +
+ )} + + {resentMessage && ( +
+ {resentMessage} +
+ )} + +
+
+ + +
+ {errors.code ? ( +

{errors.code.message}

+ ) : ( +

Enter the OTP sent to your phone

+ )} + {otpValue.length}/6 +
+
+ + + + + +

+ Didn't receive the code? + +

+
+
); -} \ No newline at end of file +} From e84451ca6050f43b02dd801729256740f7fe48eb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:40:07 +0300 Subject: [PATCH 09/15] fix: unused import causing error --- apps/edr-freight-api/src/app.module.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 6b0f072e8..f2ca61569 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,7 +23,6 @@ import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; - @Module({ imports: [ ConfigModule.forRoot({ @@ -58,7 +57,7 @@ export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, - ) {} + ) { } async onApplicationBootstrap() { await this.seeder.run(); From 6457b692b8f5585e0c676752368e4ca41012bbcf Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:42:45 +0300 Subject: [PATCH 10/15] chore: update create customer dto --- packages/types/src/freight/index.ts | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 48d3f87af..da109ecda 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -64,17 +64,29 @@ export interface ICustomer extends BaseEntity { } export interface CreateCustomerDto { - name: string; + userId: string; + firstName: string; + lastName: string; email: string; phone: string; - company?: string; - customerType?: CustomerType; - status?: CustomerStatus; - tinNumber?: string; - city?: string; - country?: string; - address?: string; - taxId?: string; + companyName: string; + companyEmail: string; + companyPhone: string; + companyLocation: string; + companyAddress: string; + contactPersonName: string; + contactPersonPhone: string; + tinNumber: string; + vatNumber: string; + fanNumber: string; + generalManagerName: string; + generalManagerEmail: string; + generalManagerPhone: string; + poaName?: string; + poaPhone?: string; + poaAddress?: string; + poaEmail?: string; + poaLocation?: string; notes?: string; } From 10287b4bfdd316b4362816fe272ea5c401da48a8 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:47:36 +0300 Subject: [PATCH 11/15] style(auth): Apply updated design system guidelines to all authentication forms --- .../portal/src/components/auth/AuthLayout.tsx | 40 ++--- .../portal/src/pages/accounts/LoginPage.tsx | 147 ++++++++-------- .../src/pages/accounts/SetPasswordPage.tsx | 130 ++++++++------ .../portal/src/pages/accounts/SignupPage.tsx | 162 ++++++++++-------- .../pages/accounts/VerificationOtpPage.tsx | 111 +++++++----- 5 files changed, 324 insertions(+), 266 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index c4b908a09..310cac6ca 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -21,7 +21,7 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) { return (
-
+
@@ -29,16 +29,22 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
-

EDR Freight

-

Railway Logistics Platform

+

EDR Freight

+

+ Railway Logistics Platform +

-
+
{left.badge}
-

{left.title}

-

{left.description}

+

+ {left.title} +

+

+ {left.description} +

{left.features.map((item) => ( @@ -51,33 +57,21 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) { ))}
-
-
-
-

{left.stats.label}

-

{left.stats.value}

-
-
{left.stats.footer}
-
-
-
-
-
-
+

EDR Freight

-

Railway Logistics Platform

+

+ Railway Logistics Platform +

-
- {children} -
+
{children}
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 0af8d0af2..c3238f3d4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -3,6 +3,14 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; type LoginMethod = "email" | "phone"; @@ -46,116 +54,117 @@ export default function LoginPage() { "Enterprise-grade operations", "Multi-corridor freight monitoring", ], - stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, + stats: { + label: "Active Corridors", + value: "24+", + footer: "Operational", + progress: "w-[95%]", + }, }} > -
-
- +
+
+
-

Welcome back

-

+

Welcome back

+

Enter your credentials to access your portal

-
+
- - +
-
- - setIdentifier(e.target.value)} - required - disabled={loading} - className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" - /> -
+ + + + {method === "email" ? "Email Address" : "Phone Number"} + + setIdentifier(e.target.value)} + required + disabled={loading} + /> + -
-
- - -
- setPassword(e.target.value)} - required - disabled={loading} - className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" - /> -
+ +
+ Password + +
+ setPassword(e.target.value)} + required + disabled={loading} + /> +
+
{error && ( -
+
{error}
)} - +

Don't have an account?{" "} - +

diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 268cf836e..c8f2a7184 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -3,9 +3,17 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react"; +import { ArrowRight, Eye, EyeOff, LockKeyhole, Loader2 } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const passwordSchema = z .object({ @@ -72,81 +80,91 @@ export default function SetPasswordPage() { stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, }} > -
-
- +
+
+
-

Set Password

-

- Create a secure password for your EDR Freight account. +

Set Password

+

+ Create a secure password for your account.

{error && ( -
+
{error}
)} -
-
- -
- - -
- {errors.password &&

{errors.password.message}

} -
+ + + + Password +
+ + +
+ +
-
- -
- - -
- {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
+ + Confirm Password +
+ + +
+ +
+
- +
); 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 05affbbe8..0b4523453 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -3,11 +3,19 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, UserPlus } from "lucide-react"; +import { ArrowRight, UserPlus, Loader2 } from "lucide-react"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const userSchema = z.object({ email: z.string().email("Invalid email address"), @@ -86,107 +94,115 @@ export default function SignupPage() { stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, }} > -
-
- +
+
+
-

Create Account

-

- Register to access EDR Freight services and railway logistics operations. +

Create Account

+

+ Register to access EDR Freight services.

{error && ( -
+
{error}
)} -
-
- - - {errors.name?.en &&

{errors.name.en.message}

} -
- -
- - - {errors.username &&

{errors.username.message}

} -
- -
- - - {errors.email &&

{errors.email.message}

} -
- -
- -
- + + + Full Name + - -
- {(errors.countryCode || errors.phone) && ( -

- {errors.countryCode?.message || errors.phone?.message} -

- )} -
+ + -
+ + + Phone Number +
+ + +
+ +
+ + + +

Already have an account? - +

diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index 183980e4f..a09417c01 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -3,10 +3,18 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, MailCheck, RotateCw } from "lucide-react"; +import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react"; import { verificationCodeType } from "@/enums/verificationCodeType"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const otpSchema = z.object({ code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), @@ -88,92 +96,105 @@ export default function VerificationOtpPage() { stats: { label: "Verification Security", value: "99.9%", footer: "Protected", progress: "w-[99%]" }, }} > -
-
- +
+
+
-

OTP Verification

-

Enter the 6-digit code sent to:

-
-

{maskedPhone}

+

OTP Verification

+

Enter the 6-digit code sent to:

+
+

{maskedPhone}

{error && ( -
+
{error}
)} {resentMessage && ( -
+
{resentMessage}
)} -
-
- - -
- {errors.code ? ( -

{errors.code.message}

- ) : ( -

Enter the OTP sent to your phone

- )} - {otpValue.length}/6 -
-
+ + + + Verification Code + +
+ {errors.code ? ( + + ) : ( +

Enter the OTP sent to your phone

+ )} + {otpValue.length}/6 +
+
+
- + - +

Didn't receive the code? - +

From edfd885b4caaefd9da2f7e6010a035e5c01d384d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:49:49 +0300 Subject: [PATCH 12/15] feat(auth): Implement phone number login and introduce PhoneInput component --- .../portal/src/components/auth/PhoneInput.tsx | 43 +++++++++++++++++++ .../portal/src/pages/accounts/LoginPage.tsx | 42 ++++++++++++------ .../portal/src/pages/accounts/SignupPage.tsx | 29 ++++--------- 3 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx new file mode 100644 index 000000000..e490a28ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx @@ -0,0 +1,43 @@ +import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common"; + +interface PhoneInputProps { + disabled?: boolean; + countryCode?: React.ComponentProps; + phone?: React.ComponentProps; + countryCodeError?: { message?: string }; + phoneError?: { message?: string }; + label?: string; +} + +export default function PhoneInput({ + disabled, + countryCode: countryCodeProps, + phone: phoneProps, + countryCodeError, + phoneError, + label = "Phone Number", +}: PhoneInputProps) { + return ( + + {label} +
+ + +
+ +
+ ); +} 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 c3238f3d4..4cfc64619 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -11,6 +11,7 @@ import { FieldError, FieldGroup, } from "@edr/ui-common"; +import PhoneInput from "@/components/auth/PhoneInput"; type LoginMethod = "email" | "phone"; @@ -19,6 +20,8 @@ export default function LoginPage() { const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); + const [countryCode, setCountryCode] = useState("+251"); + const [phoneNumber, setPhoneNumber] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -28,7 +31,10 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const result = await login({ email: identifier, password }); + const loginId = method === "email" + ? identifier + : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; + const result = await login({ email: loginId, password }); if (result.success) { navigate("/"); } else { @@ -97,19 +103,31 @@ export default function LoginPage() {
- - - {method === "email" ? "Email Address" : "Phone Number"} - - setIdentifier(e.target.value)} - required + {method === "email" ? ( + + Email Address + setIdentifier(e.target.value)} + required + disabled={loading} + /> + + ) : ( + ) => setCountryCode(e.target.value), + }} + phone={{ + value: phoneNumber, + onChange: (e: React.ChangeEvent) => setPhoneNumber(e.target.value), + }} /> - + )}
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 0b4523453..43f1abb21 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -8,6 +8,7 @@ import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; import { Button, Input, @@ -150,27 +151,13 @@ export default function SignupPage() {
- - Phone Number -
- - -
- -
+
@@ -164,31 +93,24 @@ export default function MyPortalPage() { // ------------------------------------------------------------ const activeBookings = myBookings.filter( - (b) => - b.status === "Confirmed" || - b.status === "In Transit" + (b) => b.status === "Confirmed" || b.status === "In Transit", ); - const activeShipments = myShipments.filter( - (s) => s.status === "In Transit" - ); + const activeShipments = myShipments.filter((s) => s.status === "In Transit"); const outstandingInvoices = myInvoices.filter( - (i) => i.status === "Sent" || i.status === "Overdue" + (i) => i.status === "Sent" || i.status === "Overdue", ); const totalOutstanding = outstandingInvoices.reduce( - (sum, i) => - i.currency === "USD" ? sum + i.amount : sum, - 0 + (sum, i) => (i.currency === "USD" ? sum + i.amount : sum), + 0, ); const totalSpent = myInvoices.reduce( (sum, i) => - i.status === "Paid" && i.currency === "USD" - ? sum + i.amount - : sum, - 0 + i.status === "Paid" && i.currency === "USD" ? sum + i.amount : sum, + 0, ); // ------------------------------------------------------------ @@ -198,13 +120,11 @@ export default function MyPortalPage() { return (
- {/* HERO */}
-
{customer.companyName?.charAt(0)} @@ -244,7 +164,6 @@ export default function MyPortalPage() { {/* KPI */}
-
-

- {label} -

+

{label}

-

- {value} -

+

{value}

-

- {sub} -

+

{sub}

+ {content} ); } - return ( -
- {content} -
- ); -} \ No newline at end of file + return
{content}
; +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index d05807090..ac1a708da 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -81,11 +81,7 @@ export const api = { "verifyOTP", authService.verifyOTP, ), - logout: endpoint( - "auth", - "logout", - authService.logout, - ), + logout: endpoint("auth", "logout", authService.logout), }, customers: { @@ -115,7 +111,7 @@ export const api = { customersService.remove(id), ), - getByUserId: endpoint<{ id: string }, Customer>( + getByUserId: endpoint<{ id: string }, Customer | null>( "customers", "getByUserId", ({ id }) => customersService.getByUserId(id), From 3e77f27a96fb9a1170cebfb52899edb941265edb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 15:53:57 +0300 Subject: [PATCH 15/15] feat(onboarding): Introduce multi-step customer onboarding page, enhancing AuthLayout with dynamic class props and improving customer service for typed creation and graceful 404 handling. --- .../portal/src/components/auth/AuthLayout.tsx | 21 +- .../src/pages/accounts/OnboardingPage.tsx | 520 ++++++++++++++++++ .../portal/src/services/customers.service.ts | 31 +- 3 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 310cac6ca..9d023a3ec 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,8 +1,11 @@ import type { ReactNode } from "react"; import { ShieldCheck, Train } from "lucide-react"; +import { cn } from "@/lib/utils"; export interface AuthLayoutProps { children: ReactNode; + parentClassName?: string; + contentClassName?: string; left: { badge: string; title: string; @@ -17,10 +20,15 @@ export interface AuthLayoutProps { }; } -export default function AuthLayout({ children, left }: AuthLayoutProps) { +export default function AuthLayout({ + children, + parentClassName, + contentClassName, + left, +}: AuthLayoutProps) { return (
-
+
@@ -58,8 +66,13 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
-
-
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx new file mode 100644 index 000000000..cea9d1774 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -0,0 +1,520 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + User, + FileText, + CheckCircle2, + Loader2, +} from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { CreateCustomerDto } from "@/types/customers"; +import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type OnboardingStep = "company" | "personnel" | "poa"; + +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"), + 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"), + 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"), + poaName: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +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", + ], + poa: [], +}; + +export default function OnboardingPage() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(onboardingSchema), + defaultValues: { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + vatNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaAddress: "", + poaEmail: "", + poaLocation: "", + }, + }); + + const createCustomerMutation = useMutation({ + mutationFn: (payload: CreateCustomerDto) => + api.customers.create.call(payload), + onSuccess: () => { + if (user) + queryClient.invalidateQueries({ + queryKey: api.customers.getByUserId.queryKey({ id: user.id }), + }); + }, + }); + + const nextStep = async () => { + if (step === "poa") { + handleSubmit(onSubmit)(); + return; + } + const fields = stepFields[step]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep(step === "company" ? "personnel" : "poa"); + }; + + const prevStep = () => { + if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + }; + + const onSubmit = async (data: FormData) => { + const nameParts = (user?.name?.en ?? "").split(" "); + const payload: CreateCustomerDto = { + userId: user!.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user!.email, + phone: user!.phoneNumber, + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + tinNumber: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }; + createCustomerMutation.mutate(payload); + }; + + return ( + +
+
+
+ } + active={step === "company"} + completed={step !== "company"} + /> + } + active={step === "personnel"} + completed={step === "poa"} + /> + } + active={step === "poa"} + completed={false} + /> +
+

+ {step === "company" && "Step 1 of 3 — Company Information"} + {step === "personnel" && "Step 2 of 3 — Personnel Details"} + {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} +

+
+ +
+ + {step === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + VAT Number + + + +
+ + + FAN Number (16 digits) + + + + + )} + + {step === "personnel" && ( + <> +

+ Personal details are pulled from your account. Contact and + management info is collected below. +

+ +
+

+ Contact Person +

+
+ + Name + + + + + +
+
+ +
+ +
+

+ General Manager +

+
+ + Name + + + + + + Email + + + + + +
+
+ + )} + + {step === "poa" && ( + <> +

+ Power of Attorney details are optional. Skip if not applicable. +

+ + + PoA Name + + + +
+ + PoA Email + + + + +
+ +
+ + PoA Location + + + + + PoA Address + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/customers.service.ts b/apps/edr-freight-web/portal/src/services/customers.service.ts index 3781859fa..3d809709d 100644 --- a/apps/edr-freight-web/portal/src/services/customers.service.ts +++ b/apps/edr-freight-web/portal/src/services/customers.service.ts @@ -7,6 +7,7 @@ import type { Customer, UpdateCustomerDto, } from "@/types/customers"; +import { isAxiosError } from "axios"; const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE; @@ -23,22 +24,26 @@ export const customersService = { return unwrap(response.data); }, - getByUserId: async (userId: string): Promise => { - const response = await client.get>( - URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), - ); + getByUserId: async (userId: string): Promise => { + try { + const response = await client.get>( + URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), + ); + return unwrap(response.data); + } catch (e) { + if (isAxiosError(e) && e.response?.status === 404) { + return null; + } + throw e; + } + }, + + create: async (payload: CreateCustomerDto): Promise => { + const response = await client.post>(BASE, payload); return unwrap(response.data); }, - create: async (payload: any): Promise => { - const response = await client.post>(BASE, payload); - return unwrap(response.data); - }, - - update: async ( - id: string, - payload: UpdateCustomerDto, - ): Promise => { + update: async (id: string, payload: UpdateCustomerDto): Promise => { const response = await client.patch>( URL_CONSTANTS.CUSTOMERS_API.BY_ID(id), payload,