From 63f638d1bba63dca9999c2161f72843e69a7bcf5 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 08:52:35 +0000 Subject: [PATCH 01/36] fix: session expiry during usage and also login error message --- apps/edr-freight-web/backoffice/.env.example | 4 + .../backoffice/src/auth/AuthProvider.tsx | 16 ++++ .../backoffice/src/auth/http.ts | 39 +++++--- .../backoffice/src/auth/refreshScheduler.ts | 82 +++++++++++++++++ .../backoffice/src/utils/result.ts | 34 ++++++- apps/edr-freight-web/portal/src/App.tsx | 19 +++- apps/edr-freight-web/portal/src/utils/api.ts | 89 ++++++++++--------- .../portal/src/utils/refreshScheduler.ts | 81 +++++++++++++++++ .../portal/src/utils/result.ts | 34 ++++++- 9 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts create mode 100644 apps/edr-freight-web/portal/src/utils/refreshScheduler.ts diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/backoffice/src/utils/result.ts +++ b/apps/edr-freight-web/backoffice/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { 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", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 7567c485d..697c2f89c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -24,6 +24,10 @@ import OnboardingResumeBanner, { } from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import useAuth from "./hooks/useAuth"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./utils/refreshScheduler"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company, companyType, createProfileAndSwitch } = useAuth(); + const { user, company, companyType, createProfileAndSwitch, isAuthenticated } = + useAuth(); + + // Keep the server session alive while a user is logged in. Runs after + // login, signup, and page-reload bootstrap alike. + useEffect(() => { + if (!isAuthenticated) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [isAuthenticated]); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 289709906..6cc569477 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -42,21 +42,41 @@ client.interceptors.request.use((config) => { }); // Token refresh state -let isRefreshing = false; -let failedQueue: { - resolve: (token: string) => void; - reject: (error: unknown) => void; -}[] = []; +let refreshPromise: Promise | null = null; -function processQueue(error: unknown, token?: string) { - failedQueue.forEach(({ resolve, reject }) => { - if (error) { - reject(error); - } else { - resolve(token!); +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +async function refreshSessionTokens(): Promise { + refreshPromise ??= (async () => { + const refreshToken = getCookie("refresh-token"); + if (!refreshToken) { + throw new Error("missing refresh token"); } + + type TokenPair = { token: string; refreshToken: string }; + const { data } = await client.post & { data?: TokenPair }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + // The API returns the pair flat ({ success, token, refreshToken }); accept + // a { data: { ... } }-wrapped shape too so a transform change can't + // silently break refresh again. + const payload = data.data ?? data; + if (!payload.token || !payload.refreshToken) { + throw new Error("malformed refresh-token response"); + } + setCookie("auth-token", payload.token, 7); + setCookie("refresh-token", payload.refreshToken, 7); + return payload.token; + })().finally(() => { + refreshPromise = null; }); - failedQueue = []; + + return refreshPromise; } // Handle auth errors globally with token refresh @@ -72,54 +92,41 @@ client.interceptors.response.use( // - status is not 401 // - already retried // - it's the refresh endpoint itself + // - it's a credential endpoint (401 there = wrong credentials, not an + // expired session — refreshing would mask the real error) if ( !error.response || error.response.status !== 401 || originalRequest._retry || - originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN || + originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || + originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { 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(); + // Nothing to refresh with (e.g. not logged in yet) — surface the + // original error instead of a confusing refresh failure. + if (!getCookie("refresh-token")) { + if (getCookie("auth-token")) { + // Half-broken cookie state; reset it. + clearAuthCookies(); + } return Promise.reject(error); } + originalRequest._retry = true; + 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); + const token = await refreshSessionTokens(); originalRequest.headers.Authorization = `Bearer ${token}`; - processQueue(null, token); return client(originalRequest); } catch (refreshError) { - processQueue(refreshError, undefined); clearAuthCookies(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } }, ); -export { client }; +export { client, clearAuthCookies, getCookie, refreshSessionTokens }; export type { UseQueryOptions, QueryObserverOptions }; diff --git a/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts new file mode 100644 index 000000000..dceca856e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts @@ -0,0 +1,81 @@ +import { isAxiosError } from "axios"; + +import { + clearAuthCookies, + getCookie, + refreshSessionTokens, +} from "./api"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in api.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie("refresh-token")) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearAuthCookies(); + window.location.replace("/login"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { 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", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } From 3a1d73b725c8498c8c33ffb442cf465d44134c78 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 7 Jul 2026 13:36:45 +0000 Subject: [PATCH 02/36] fix last mile --- .../modules/bookings/bookings.controller.ts | 85 ++++++++++ .../src/modules/bookings/bookings.module.ts | 2 + .../gps-tracking/gps-tracking.controller.ts | 11 +- .../src/seed/freight-permissions.registry.ts | 2 + .../backoffice/src/lib/permissions.ts | 1 + .../src/pages/fleet/TrackingPage.tsx | 39 +++-- .../BookingDetailPage/ReadonlyBookingView.tsx | 3 + .../components/MileSummaryCard.tsx | 156 ++++++++++++++++++ .../portal/src/services/bookings.service.ts | 24 +++ 9 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..974cf06a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -81,6 +83,60 @@ import { hasFreightPermission, } from "../../common/freight-permission.util"; +interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} + +interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} + +/** Trim a first/last-mile record down to a customer-safe operational summary. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeMileLeg(rec?: Record): MileLegSummary | null { + if (!rec) return null; + const num = (v: unknown) => (v == null ? null : Number(v)); + const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any + const currency = + rec.vehicle?.currency ?? + assignments[0]?.vehicle?.currency ?? + rec.booking?.paymentCurrency ?? + 'ETB'; + const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ + plate: a.vehicle?.plateNumber ?? null, + code: a.vehicle?.code ?? null, + driverName: a.vehicle?.assignedDriverName ?? null, + containerNumber: a.containerNumber ?? null, + distanceKm: num(a.distanceKm), + })); + if (!vehicles.length && rec.vehicle) { + vehicles.push({ + plate: rec.vehicle.plateNumber ?? null, + code: rec.vehicle.code ?? null, + driverName: rec.vehicle.assignedDriverName ?? null, + containerNumber: null, + distanceKm: num(rec.exactKm), + }); + } + return { + status: rec.status ?? '', + exactKm: num(rec.exactKm), + remainingPayment: num(rec.remainingPayment), + currency, + invoiced: Boolean(rec.invoice), + vehicles, + }; +} + @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() @@ -94,6 +150,8 @@ export class BookingsController { private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, + private readonly firstMileService: FirstMileService, + private readonly lastMileService: LastMileService, ) {} @Post() @@ -290,6 +348,33 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/mile-summary') + @ApiOperation({ + summary: 'First/last-mile operational summary for a booking (customer-safe)', + }) + async mileSummary( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Customers may only see their own booking's mile summary. + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + + const [first, last] = await Promise.all([ + this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), + this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), + ]); + return { + firstMile: summarizeMileLeg(first.data[0]), + lastMile: summarizeMileLeg(last.data[0]), + }; + } + @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 61dc78e13..82fc19e62 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentController } from './booking-payment.controller'; @@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), + forwardRef(() => LastMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), forwardRef(() => ContractsModule), diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index e380e541e..8bd4a31d8 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -11,14 +11,15 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { GpsTrackingService } from './gps-tracking.service'; import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@FleetView() +@BookingStaff(FREIGHT_PERMS.tracking.view) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} @@ -44,21 +45,21 @@ export class GpsTrackingController { } @Post('devices') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Register a GPS tracker' }) register(@Body() dto: RegisterDeviceDto) { return this.gps.registerDevice(dto); } @Patch('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { return this.gps.updateDevice(id, dto); } @Delete('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Delete a GPS tracker' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.gps.removeDevice(id); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..8d70bc61c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), @@ -477,6 +478,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: 'edr_freight_app:tracking:view', + manage: 'edr_freight_app:tracking:manage', }, fuel: { view: 'edr_freight_app:fuel:view', diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c8bfa7c84..e0c5c3d08 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -145,6 +145,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: "edr_freight_app:tracking:view", + manage: "edr_freight_app:tracking:manage", }, fuel: { view: "edr_freight_app:fuel:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 14bb32d33..958d99775 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -27,6 +27,8 @@ import { import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { vehiclesService } from "@/services/vehicles.service"; import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; @@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) { export function TrackingPage() { const { toast } = useToast(); const qc = useQueryClient(); + const { user } = useAuth(); + const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage); const [selectedId, setSelectedId] = useState(null); const [hoverId, setHoverId] = useState(null); const [mapsReady, setMapsReady] = useState(false); @@ -288,9 +292,11 @@ export function TrackingPage() { Real-Time Vehicle Tracking Live GPS positions from GT06 trackers - + {canManage && ( + + )} @@ -358,9 +364,11 @@ export function TrackingPage() { }> {selected.online ? "Live" : "Offline"} - deleteMutation.mutate(selected.id)}> - - + {canManage && ( + deleteMutation.mutate(selected.id)}> + + + )} @@ -393,6 +401,7 @@ export function TrackingPage() { data={vehicleOptions} value={selected.vehicleId ?? null} onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })} + disabled={!canManage} searchable clearable /> @@ -421,14 +430,16 @@ export function TrackingPage() { {d.online ? "Live" : "Offline"} - { e.stopPropagation(); openEdit(d); }} - > - - + {canManage && ( + { e.stopPropagation(); openEdit(d); }} + > + + + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 6d274f22f..6a002e3e9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; +import { MileSummaryCard } from "./components/MileSummaryCard"; import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, @@ -217,6 +218,8 @@ export function ReadonlyBookingView({ + + } right={ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx new file mode 100644 index 000000000..5128ddecd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -0,0 +1,156 @@ +import { Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { bookingsService } from "@/services/bookings.service"; +import type { + MileLegSummary, + MileVehicleSummary, +} from "@/services/bookings.service"; + +import { CardTitle, SectionCard } from "./layout"; + +function StatusPill({ status }: { status: string }) { + const s = status.toUpperCase(); + const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID"); + const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN"); + const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8"; + const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569"; + const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7"; + const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE"; + const label = status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + + return ( + + + {label} + + ); +} + +function VehicleRow({ v }: { v: MileVehicleSummary }) { + const parts: string[] = []; + if (v.driverName) parts.push(v.driverName); + if (v.containerNumber) parts.push(`Container ${v.containerNumber}`); + if (v.distanceKm != null) parts.push(`${v.distanceKm} km`); + + return ( + + + + {v.plate || v.code || "Vehicle"} + + {parts.length > 0 && ( + + {parts.join(" · ")} + + )} + + {v.code && v.plate && ( + + {v.code} + + )} + + ); +} + +function LegBlock({ title, leg }: { title: string; leg: MileLegSummary }) { + const fmtMoney = (n: number | null) => + n == null + ? null + : `${leg.currency} ${Number(n).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + + return ( + + + + {title} + + + + + {leg.vehicles.length > 0 ? ( + + {leg.vehicles.map((v, i) => ( + + ))} + + ) : ( + + No vehicle assigned yet. + + )} + + + + {leg.exactKm != null && ( + + Total distance {leg.exactKm} km + + )} + {leg.remainingPayment != null && leg.remainingPayment > 0 && ( + + Balance {fmtMoney(leg.remainingPayment)} + + )} + + {leg.invoiced && ( + + Invoiced + + )} + + + ); +} + +export function MileSummaryCard({ bookingId }: { bookingId: string }) { + const { data } = useQuery({ + queryKey: ["booking-mile-summary", bookingId], + queryFn: () => bookingsService.mileSummary(bookingId), + }); + + if (!data || (!data.firstMile && !data.lastMile)) return null; + + return ( + + + First & Last Mile + + + {data.firstMile && } + {data.lastMile && } + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index c2729bebb..8112fabbd 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -7,6 +7,26 @@ import { client } from "../utils/api"; const B = URL_CONSTANTS.BOOKINGS; +export interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} +export interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} +export interface MileSummaryResponse { + firstMile: MileLegSummary | null; + lastMile: MileLegSummary | null; +} + export type CreateBookingPayload = Freight.CreateBookingDto; export interface ContractView { @@ -150,6 +170,10 @@ export const bookingsService = { const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, + mileSummary: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/mile-summary`); + return data.data; + }, assignCustomerTruck: async ( id: string, payload: CustomerTruckAssignmentPayload, From 8f23f85e63c29bcb33ce333cd9ad33b80233e2cc Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 7 Jul 2026 23:55:35 +0300 Subject: [PATCH 03/36] Set nationality required on booking widget --- .../portal/src/app/booking/results/page.tsx | 1057 +++++++++++------ .../portal/src/app/booking/search/page.tsx | 107 +- 2 files changed, 779 insertions(+), 385 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 77a18380f..66f91dd60 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,57 +1,106 @@ -'use client'; +"use client"; -import { useSearchParams, useRouter } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; -import { useBookingStore } from '@/lib/booking-store'; -import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react'; -import { format } from 'date-fns'; -import { formatTime, getTimePeriod } from '@/utils/format'; -import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { useBookingStore } from "@/lib/booking-store"; +import { Schedule } from "@/types"; +import { + ArrowRight, + Clock, + Calendar, + Users, + ChevronLeft, + Check, + X, + MapPin, + Gift, + Train, + Bed, + Armchair, + Star, +} from "lucide-react"; +import { format } from "date-fns"; +import { formatTime, getTimePeriod } from "@/utils/format"; +import { useState, useEffect } from "react"; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); - const [selectedCoachTypes, setSelectedCoachTypes] = useState>({}); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = + useBookingStore(); + const [selectedCoachTypes, setSelectedCoachTypes] = useState< + Record + >({}); const [outboundScheduleData, setOutboundScheduleData] = useState( () => useBookingStore.getState().outboundSchedule, ); const [classModal, setClassModal] = useState(null); - const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); - const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => { - const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState(); - return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound'; - }); + const [promoData, setPromoData] = useState<{ + code: string; + discount: string; + message: string; + } | null>(null); + const [roundTripStep, setRoundTripStep] = useState<"outbound" | "inbound">( + () => { + const { outboundSchedule, searchCriteria: sc } = + useBookingStore.getState(); + return outboundSchedule && sc?.tripType === "ROUND_TRIP" + ? "inbound" + : "outbound"; + }, + ); const searchCriteria = useBookingStore((s) => s.searchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const searchData = { - originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '', - destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '', - date: searchParams.get('date') || searchCriteria?.departureDate || '', - returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate, - journeyType: (searchParams.get('tripType') ?? searchCriteria?.tripType ?? 'ONE_WAY') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', - adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1, - childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0, - nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN', - promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '', + originStationId: + searchParams.get("origin") || searchCriteria?.originStationId || "", + destinationStationId: + searchParams.get("destination") || + searchCriteria?.destinationStationId || + "", + date: searchParams.get("date") || searchCriteria?.departureDate || "", + returnDate: searchParams.get("returnDate") || searchCriteria?.returnDate, + journeyType: + (searchParams.get("tripType") ?? + searchCriteria?.tripType ?? + "ONE_WAY") === "ROUND_TRIP" + ? "ROUND_TRIP" + : "ONE_WAY", + adultCount: + parseInt(searchParams.get("adults") || "") || + searchCriteria?.adultCount || + 1, + childCount: + parseInt(searchParams.get("children") || "") || + searchCriteria?.childCount || + 0, + nationality: + searchParams.get("nationality") || + searchCriteria?.nationality || + "ETHIOPIAN", + promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "", }; useEffect(() => { - if (searchParams.get('origin')) { + if (searchParams.get("origin")) { setSearchCriteria({ - tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP', - originStationId: searchParams.get('origin')!, - destinationStationId: searchParams.get('destination')!, - departureDate: searchParams.get('date')!, - returnDate: searchParams.get('returnDate') || undefined, - adultCount: parseInt(searchParams.get('adults') || '1'), - childCount: parseInt(searchParams.get('children') || '0'), - nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER', - promoCode: searchParams.get('promoCode') || '', + tripType: (searchParams.get("tripType") || "ONE_WAY") as + | "ONE_WAY" + | "ROUND_TRIP", + originStationId: searchParams.get("origin")!, + destinationStationId: searchParams.get("destination")!, + departureDate: searchParams.get("date")!, + returnDate: searchParams.get("returnDate") || undefined, + adultCount: parseInt(searchParams.get("adults") || "1"), + childCount: parseInt(searchParams.get("children") || "0"), + nationality: (searchParams.get("nationality") || "ETHIOPIAN") as + | "ETHIOPIAN" + | "DJIBOUTIAN" + | "OTHER", + promoCode: searchParams.get("promoCode") || "", }); } }, [searchParams, setSearchCriteria]); @@ -59,13 +108,13 @@ export default function ResultsPage() { useEffect(() => { if (searchData.promoCode) { apiClient - .post('/promos/validate', { code: searchData.promoCode }) + .post("/promos/validate", { code: searchData.promoCode }) .then((response: any) => { if (response.applicable || response.valid) { setPromoData({ code: searchData.promoCode, - discount: response.message || 'Discount applied', - message: response.message || 'Promo code applied successfully!', + discount: response.message || "Discount applied", + message: response.message || "Promo code applied successfully!", }); } }) @@ -90,8 +139,12 @@ export default function ResultsPage() { return `/booking/search?${params}`; }; - const { data: results, isLoading, error } = useQuery({ - queryKey: ['search', searchData], + const { + data: results, + isLoading, + error, + } = useQuery({ + queryKey: ["search", searchData], queryFn: async (): Promise => { const payload: any = { originStationId: searchData.originStationId, @@ -102,15 +155,13 @@ export default function ResultsPage() { nationality: searchData.nationality, journeyType: searchData.journeyType, }; - - if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) { + + if (searchData.journeyType === "ROUND_TRIP" && searchData.returnDate) { payload.returnDate = searchData.returnDate; } - - - const response = await apiClient.post('/search', payload) as any; - - + + const response = (await apiClient.post("/search", payload)) as any; + return response; }, enabled: !!searchData.originStationId && !!searchData.destinationStationId, @@ -118,20 +169,20 @@ export default function ResultsPage() { gcTime: 0, }); - const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; - + const isRoundTrip = searchData.journeyType === "ROUND_TRIP"; + // Handle both response formats: // 1. One-way: response can be array of schedules OR object with journeyType and outbound // 2. Round-trip: response has journeyType, outbound, inbound properties let outboundSchedules: Schedule[] = []; let inboundSchedules: Schedule[] = []; - + if (results) { - if (results.journeyType === 'ROUND_TRIP') { + if (results.journeyType === "ROUND_TRIP") { // Round trip response format outboundSchedules = results.outbound || []; inboundSchedules = results.inbound || []; - } else if (results.journeyType === 'ONE_WAY' && results.outbound) { + } else if (results.journeyType === "ONE_WAY" && results.outbound) { // One-way response format with outbound array outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { @@ -142,40 +193,68 @@ export default function ResultsPage() { outboundSchedules = results.data; } } - - // Alternatives are surfaced whenever a leg returns no exact-date results. - const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : []; - const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : []; - const requestedDate: string = (results && results.requestedDate) || searchData.date; - const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || ''; - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + // Alternatives are surfaced whenever a leg returns no exact-date results. + const alternativeOutbound: Schedule[] = + !!results && outboundSchedules.length === 0 + ? results?.alternativeOutbound || [] + : []; + const alternativeInbound: Schedule[] = + isRoundTrip && !!results && inboundSchedules.length === 0 + ? results?.alternativeInbound || [] + : []; + const requestedDate: string = + (results && results.requestedDate) || searchData.date; + const requestedReturnDate: string = + (results && results.requestedReturnDate) || searchData.returnDate || ""; + + const isOneWayNoOutbound = + !isRoundTrip && !!results && outboundSchedules.length === 0; // Round-trip: show results view if either leg has exact results OR alternatives. // One-way: need at least one outbound result. const hasResults = isRoundTrip - ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0) + ? outboundSchedules.length > 0 || + alternativeOutbound.length > 0 || + inboundSchedules.length > 0 || + alternativeInbound.length > 0 : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); + const handleSelectCoachType = ( + scheduleId: string, + coachTypeId: string, + coachTypeCode: string, + coachTypeName: string, + seatClassName: string, + ) => { + setSelectedCoachTypes((prev) => ({ + ...prev, + [scheduleId]: { + id: coachTypeId, + code: coachTypeCode, + name: coachTypeName, + seatClassName, + }, + })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { - const scheduleId = schedule.scheduleId || schedule.id || ''; + const scheduleId = schedule.scheduleId || schedule.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; - + if (!selectedCoachType) { - alert('Please select a coach type before continuing'); + alert("Please select a coach type before continuing"); return; } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); + const coachType = schedule.coachTypes?.find( + (ct) => ct.coachTypeCode === selectedCoachType.code, + ); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) + ? Math.min(...coachType.classes.map((c) => c.baseFareMinor)) : 0; - const fareCurrency = 'ETB'; + const fareCurrency = "ETB"; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -184,12 +263,13 @@ export default function ResultsPage() { const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, - origin: schedule.origin?.name || 'Origin', - destination: schedule.destination?.name || 'Destination', - originStationId: schedule.origin?.id || schedule.originStationId || '', - destinationStationId: schedule.destination?.id || schedule.destinationStationId || '', - departureTime: schedule.departureAt || schedule.departureTime || '', - arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '', + origin: schedule.origin?.name || "Origin", + destination: schedule.destination?.name || "Destination", + originStationId: schedule.origin?.id || schedule.originStationId || "", + destinationStationId: + schedule.destination?.id || schedule.destinationStationId || "", + departureTime: schedule.departureAt || schedule.departureTime || "", + arrivalTime: schedule.arrivalAt || schedule.arrivalTime || "", duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, @@ -199,7 +279,8 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, - seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, + seatClassName: + (selectedCoachType as any).seatClassName || selectedCoachType.name, // Retained so the seat map's coach preview can price a switch to a different // coach type without needing a fresh API call. coachTypes: schedule.coachTypes || [], @@ -210,8 +291,8 @@ export default function ResultsPage() { setOutboundScheduleData(scheduleData); setOutboundSchedule(scheduleData); setClassModal(null); - setRoundTripStep('inbound'); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setRoundTripStep("inbound"); + window.scrollTo({ top: 0, behavior: "smooth" }); return; } @@ -223,8 +304,8 @@ export default function ResultsPage() { // For one-way setSelectedSchedule(scheduleData); } - - router.push('/booking/auth-check'); + + router.push("/booking/auth-check"); }; // Shared "Choose Your Coach" drawer — used by both the normal results view and the @@ -233,118 +314,164 @@ export default function ResultsPage() { const renderClassModal = () => { if (!classModal) return null; - const scheduleId = classModal.scheduleId || classModal.id || ''; + const scheduleId = classModal.scheduleId || classModal.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; const isOutbound = (classModal as any).isOutbound; // Dining coaches aren't bookable seat/bed classes — exclude them from selection. - const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC'); + const coachTypes = (classModal.coachTypes || []).filter( + (ct: any) => ct.coachTypeCode !== "DPC", + ); const getCoachIcon = (typeName: string) => { const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; + if (lower.includes("soft") || lower.includes("vip")) return Star; + if (lower.includes("bed")) return Bed; return Armchair; }; return ( <> -
setClassModal(null)} /> -
setClassModal(null)} + /> +
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- +
+
+

+ Choose Your Coach +

+

+ + {classModal.trainNumber} + · + + {classModal.origin?.name} → {classModal.destination?.name} + +

+ +
-
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = + selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length + ? Math.min( + ...coachType.classes.map((c: any) => c.baseFareMinor), + ) + : 0; + const coachCurrency = "ETB"; + const CoachIcon = getCoachIcon(coachType.coachTypeName); - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

+
+ )} +
+ + ); + })} +
+ ) : ( +
+
+
+

+ No coach types available for this journey +

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

)}
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
+
-
-
-

{t('help.title')}

-

{t('help.subtitle')}

-
+
+ {/* Hero */} +
+

Help & FAQs

+

+ Find answers about booking, pricing, payments, and more. +

+
-
-
- setSearchTerm(e.target.value)} - /> - -
-
+ {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary text-sm" + /> +
+
-
-
- {searchTerm ? ( - <> - {filteredFAQs.length > 0 ? ( - filteredFAQs.map((faq) => ( -
-
- {faq.question} -
-
{faq.answer}
-
- )) - ) : ( -
- No FAQs found for "{searchTerm}" -
- )} - - ) : ( - faqCategories.map((category, catIdx) => ( -
-

{category.title}

- {category.items.map((item, itemIdx) => { - const globalIdx = catIdx * 100 + itemIdx; - const isOpen = openIndexes.includes(globalIdx); + {/* Content */} +
+ {query ? ( + searchResults.length > 0 ? ( +
+

+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for “{searchTerm}” +

+ {searchResults.map(({ catTitle, item, key }) => ( + toggle(key)} + /> + ))} +
+ ) : ( +
+ +

No results for “{searchTerm}”

+
+ ) + ) : ( +
+ {FAQ_CATEGORIES.map((cat) => ( +
+
+ {cat.icon} +

{cat.title}

+
+
+ {cat.items.map((item, i) => { + const key = `${cat.title}-${i}`; return ( -
- - {isOpen &&
{item.answer}
} -
+ toggle(key)} + /> ); })}
- )) - )} +
+ ))}
-
+ )} +
-
-
-
- -
-

{t('help.help')}

-

{t('help.contact')}

- Contact Support + {/* Contact CTA */} +
+
+
+
-
-
- +

Still need help?

+

+ Our support team is available to assist you. +

+ + Contact Support + +
+ + + ); +} + +function FAQRow({ + question, + answer, + badge, + isOpen, + onToggle, +}: { + question: string; + answer: string; + badge?: string; + isOpen: boolean; + onToggle: () => void; +}) { + return ( +
+ + {isOpen && ( +
+ {answer} +
+ )} +
); } diff --git a/apps/edr-passenger-web/portal/src/components/Footer.tsx b/apps/edr-passenger-web/portal/src/components/Footer.tsx index 8d1aa1394..8d4e36a3f 100644 --- a/apps/edr-passenger-web/portal/src/components/Footer.tsx +++ b/apps/edr-passenger-web/portal/src/components/Footer.tsx @@ -1,7 +1,6 @@ 'use client'; import { Mail, Phone, MapPin } from 'lucide-react'; -import Link from 'next/link'; import { useLanguage, getTranslation, Language } from '@/lib/i18n'; import { useEffect, useState } from 'react'; @@ -21,83 +20,27 @@ export function Footer() {