From feafb9d19e2609ac3ec7b5cad38378aa92373b48 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:53:48 +0300 Subject: [PATCH 01/33] Update sync-env-from-server-jenkins.sh --- scripts/deploy/sync-env-from-server-jenkins.sh | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/scripts/deploy/sync-env-from-server-jenkins.sh b/scripts/deploy/sync-env-from-server-jenkins.sh index 74b9a3f13..e444996d6 100644 --- a/scripts/deploy/sync-env-from-server-jenkins.sh +++ b/scripts/deploy/sync-env-from-server-jenkins.sh @@ -12,24 +12,23 @@ # Server layout (one file per service): # /home/user/environmen///freight-api.env # /home/user/environmen///freight-portal.env - set -euo pipefail - DEPLOY_USER="${DEPLOY_USER:-tria}" BRANCH="${BRANCH:?BRANCH is required}" BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}" ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}" CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/.env)}" - if [[ ! -d "${ENV_ROOT}" ]]; then echo "Environment directory not found: ${ENV_ROOT}" >&2 exit 1 fi echo "Using environment directory: ${ENV_ROOT}" - mkdir -p "$(dirname "${CI_ENV_FILE}")" +if [[ -d "${CI_ENV_FILE}" ]]; then + echo "Removing stale directory at ${CI_ENV_FILE}" >&2 + rm -rf "${CI_ENV_FILE}" +fi : > "${CI_ENV_FILE}" - declare -A SERVICE_ENV_TARGET=( ["freight-api"]="apps/edr-freight-api/.env" ["freight-portal"]="apps/edr-freight-web/portal/.env" @@ -39,36 +38,29 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" ) - for service in "$@"; do src="${ENV_ROOT}/${service}.env" dest="${SERVICE_ENV_TARGET[${service}]:-}" - if [[ -z "${dest}" ]]; then echo "Unknown service: ${service}" >&2 exit 1 fi - if [[ ! -f "${src}" ]]; then echo "Missing env file: ${src}" >&2 exit 1 fi - mkdir -p "$(dirname "${dest}")" cp "${src}" "${dest}" echo "Synced ${src} -> ${dest}" - port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]') if [[ -z "${port_value}" ]]; then echo "Missing required PORT in env file: ${src}" >&2 exit 1 fi - service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}" echo "Exported ${service_var}_PORT from ${src}" - # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \ | sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true -done \ No newline at end of file +done From e2cc9772e6cd324a0ecba36fcaf27af197ca2e5f Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 13 Jul 2026 21:42:53 +0300 Subject: [PATCH 02/33] Fix voucher pdf --- .../edr-passenger-web/portal/src/lib/generate-voucher.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 4c8f25eec..306225a6c 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -437,8 +437,11 @@ interface VoucherData { } export const generateVoucherPDF = async (booking: VoucherData): Promise => { - // The settled payment amount, when available, is shown exactly as returned by the API - // (no /100, no per-passenger split) on every passenger's voucher — see fareIsMajorUnits. + // The amount shown is always a single raw field straight from the API — the settled + // payment amount when available, otherwise the booking total — never a derived value + // (previously this fell back to Math.round(totalMinor / passengers.length), which + // doesn't correspond to any real field and could disagree with what was actually + // charged). Same value on every passenger's voucher; no /100, no per-passenger split. const settledAmountMinor = booking.payment?.amountMinor; const settledCurrency = booking.payment?.currency; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; @@ -465,7 +468,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => status: booking.status, outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, isRoundTrip: false, - fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length), + fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor, currency: voucherCurrency, fareIsMajorUnits: useSettledAmount, createdAt: booking.createdAt, From dc269bed896a66b6f1be98e7dd2d44d89510a6dd Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 13 Jul 2026 22:31:35 +0300 Subject: [PATCH 03/33] Fix roundtrip in voucher --- .../src/modules/bookings/bookings.service.ts | 18 +- .../portal/src/app/booking/detail/page.tsx | 180 +++++++++++------- .../portal/src/lib/generate-voucher.ts | 74 +++++-- 3 files changed, 188 insertions(+), 84 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index c5c8a0b49..114c0cc9d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1734,6 +1734,7 @@ export class BookingsService { where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, priceTier: { select: { priceMinor: true } }, @@ -1822,6 +1823,16 @@ export class BookingsService { destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city }, departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt, }, + returnSchedule: (booking as any).returnSchedule + ? { + id: (booking as any).returnSchedule.id, + trainNumber: (booking as any).returnSchedule.train.number, + trainName: (booking as any).returnSchedule.train.name, + origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city }, + destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city }, + departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt, + } + : null, passengers: (booking as any).seats?.map((bs: any) => ({ fullName: bs.passengerName, category: bs.passengerCategory, @@ -1844,11 +1855,14 @@ export class BookingsService { currency: (booking as any).paymentIntent.currency, } : undefined, - // One ticket per passenger — matched on the frontend by passengerName, not array - // position, since tickets are grouped/created independently of the passengers array. + // One ticket per passenger per leg (round trips have a separate ticket — and + // barcode — for the return leg) — matched on the frontend by passengerName + + // leg, not array position, since tickets are grouped/created independently of + // the passengers array. tickets: (booking as any).tickets?.map((t: any) => ({ id: t.id, passengerName: t.passengerName, + leg: t.leg ?? 1, qrPayload: t.qrPayload, barcodePayload: t.barcodePayload, status: t.status, diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 7aef411e6..6442fd0d6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -39,6 +39,35 @@ const getIconForMethod = (methodType: string) => { return Smartphone; }; +function SeatDetailsGrid({ + seat, +}: { + seat?: { coach?: string; number?: string; seatClass?: string }; +}) { + return ( +
+
+ Coach: +
+ {seat?.coach || "N/A"} +
+
+
+ Seat Number: +
+ {seat?.number || "N/A"} +
+
+
+ Class: +
+ {seat?.seatClass || "N/A"} +
+
+
+ ); +} + function BookingDetailContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -311,18 +340,22 @@ function BookingDetailContent() { }; // /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 = - // outbound, leg 2 = return) — /booking/payment's fare breakdown, by contrast, shows one - // combined row per passenger with an Outbound/Return sub-split. Group leg rows back - // together here so both pages present the same per-passenger total, not a doubled list - // of half-fare rows. + // outbound, leg 2 = return), each carrying that leg's own seat assignment — + // /booking/payment's fare breakdown, by contrast, shows one combined row per + // passenger with an Outbound/Return sub-split. Group leg rows back together here — + // by identity, not by row — so every consumer (fare breakdown, passenger/seat list) + // sees one entry per real passenger with both legs' seats attached, not a doubled + // list of half-passenger rows. const isRoundTripBooking = booking.bookingType === "ROUND_TRIP"; - const farePassengers = (() => { + const groupedPassengers = (() => { const rows: any[] = booking.passengers || []; if (!isRoundTripBooking) { return rows.map((p) => ({ fullName: p.fullName, category: p.category, fareMinor: p.fareMinor ?? 0, + outboundSeat: p.seat, + returnSeat: undefined as any, })); } const grouped = new Map< @@ -332,6 +365,8 @@ function BookingDetailContent() { category: string; outboundFareMinor: number; returnFareMinor: number; + outboundSeat?: any; + returnSeat?: any; } >(); rows.forEach((p) => { @@ -341,9 +376,16 @@ function BookingDetailContent() { category: p.category, outboundFareMinor: 0, returnFareMinor: 0, + outboundSeat: undefined, + returnSeat: undefined, }; - if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0; - else entry.outboundFareMinor = p.fareMinor ?? 0; + if (p.leg === 2) { + entry.returnFareMinor = p.fareMinor ?? 0; + entry.returnSeat = p.seat; + } else { + entry.outboundFareMinor = p.fareMinor ?? 0; + entry.outboundSeat = p.seat; + } grouped.set(key, entry); }); return Array.from(grouped.values()).map((p) => ({ @@ -352,6 +394,8 @@ function BookingDetailContent() { fareMinor: p.outboundFareMinor + p.returnFareMinor, outboundFareMinor: p.outboundFareMinor, returnFareMinor: p.returnFareMinor, + outboundSeat: p.outboundSeat, + returnSeat: p.returnSeat, })); })(); @@ -374,7 +418,7 @@ function BookingDetailContent() {

Fare breakdown

- {farePassengers.map((passenger: any, idx: number) => { + {groupedPassengers.map((passenger: any, idx: number) => { const isChildPassenger = passenger.category === "CHILD"; const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0; @@ -1035,69 +1079,77 @@ function BookingDetailContent() {

- Passenger Details ({booking.passengers?.length || 0}) + Passenger Details ({groupedPassengers.length})

- {booking.passengers?.map((passenger: any, idx: number) => ( + {groupedPassengers.map((passenger: any, idx: number) => (
-
-
-
- - {idx + 1} - -

- {passenger.fullName} -

- - {passenger.category} - -
- -
-
- - Coach: - -
- {passenger.seat?.coach || "N/A"} -
-
-
- - Seat Number: - -
- {passenger.seat?.number || "N/A"} -
-
-
- - Class: - -
- {passenger.seat?.seatClass || "N/A"} -
-
-
-
- - {isConfirmed && ( -
-
- -
-
- )} +
+ + {idx + 1} + +

+ {passenger.fullName} +

+ + {passenger.category} +
+ + {isRoundTripBooking ? ( +
+ {( + [ + { legLabel: "Outbound", seat: passenger.outboundSeat }, + { legLabel: "Return", seat: passenger.returnSeat }, + ] as const + ).map(({ legLabel, seat }) => ( +
+
+ + {legLabel} + + +
+ {isConfirmed && ( +
+
+ +
+
+ )} +
+ ))} +
+ ) : ( +
+
+ +
+ {isConfirmed && ( +
+
+ +
+
+ )} +
+ )}
))}
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 306225a6c..ac2625fad 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -419,18 +419,33 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P // ─── legacy combined voucher (kept for backward compat) ────────────────────── +interface VoucherSchedule { + trainNumber: string; + trainName?: string; + origin: { name: string; code: string; city: string }; + destination: { name: string; code: string; city: string }; + departureAt: string; + arrivalAt: string; +} + interface VoucherData { bookingRef: string; status: string; - passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>; - schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string }; + // /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 = + // outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's + // getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when + // grouping leg rows back into one passenger below. + passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>; + schedule: VoucherSchedule; + returnSchedule?: VoucherSchedule | null; totalMinor: number; currency: string; bookingType: string; createdAt: string; - // One ticket per passenger, matched below by passengerName — see bookings.service.ts's - // getByRef(). Optional/absent falls back to a client-generated placeholder number. - tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; + // One ticket per passenger per leg (round trips have a separate ticket/barcode for the + // return leg) — matched below by passengerName + leg. Optional/absent falls back to a + // client-generated placeholder number. + tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>; // The actual settled amount/currency for this booking's payment — preferred over the // ETB booking total once available, since it reflects what was really charged. payment?: { amountMinor?: number; currency?: string }; @@ -447,31 +462,54 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => const useSettledAmount = settledAmountMinor != null && !!settledCurrency; const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency; + const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule; + + // Group leg rows back into one entry per real passenger — without this, a round trip + // produced two half-passenger vouchers (one per leg, each showing only its own leg's + // seat) instead of one voucher per passenger covering both legs. + const grouped = new Map< + string, + { fullName: string; category: string; outboundSeat?: VoucherData['passengers'][number]['seat']; returnSeat?: VoucherData['passengers'][number]['seat'] } + >(); + booking.passengers.forEach((p) => { + const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`; + const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined }; + if (p.leg === 2) entry.returnSeat = p.seat; + else entry.outboundSeat = p.seat; + grouped.set(key, entry); + }); + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between // them — a setTimeout delay here would push later saves outside the click's synchronous // user-activation window and risk iOS Safari silently blocking them. The awaited work // inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode), // which doesn't have that effect. - for (let i = 0; i < booking.passengers.length; i++) { - const p = booking.passengers[i]; + for (const p of grouped.values()) { + // The displayed ticket number is always the outbound leg's — matched by leg, not just + // name, so a round trip doesn't end up showing whichever ticket happens to sort first. const matchedTicket = - booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? null; + booking.tickets?.find((t) => t.passengerName === p.fullName && (t.leg ?? 1) === 1) ?? + booking.tickets?.find((t) => t.passengerName === p.fullName) ?? + null; // No fabricated placeholder — a made-up TKT-... number reads as real and is misleading // if it doesn't match what's actually on file. const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; await generatePassengerVoucherPDF({ - bookingRef: booking.bookingRef, + bookingRef: booking.bookingRef, ticketNumber, - passengerName: p.fullName, - seatNumber: p.seat?.number, - status: booking.status, - outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, - isRoundTrip: false, - fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor, - currency: voucherCurrency, - fareIsMajorUnits: useSettledAmount, - createdAt: booking.createdAt, + passengerName: p.fullName, + status: booking.status, + outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass }, + inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined, + isRoundTrip, + seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number, + outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined, + inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined, + fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor, + currency: voucherCurrency, + fareIsMajorUnits: useSettledAmount, + createdAt: booking.createdAt, }); } }; From 5b662a2ba13132203b561dfd9943e00a02a5742c Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 13 Jul 2026 22:54:27 +0300 Subject: [PATCH 04/33] Fix voucher alignment --- .../portal/src/lib/generate-voucher.ts | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index ac2625fad..a25679d75 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -218,15 +218,18 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize); } - return y + cardH + 10; + return y + cardH + 8; } // ─── journey card ────────────────────────────────────────────────────────── function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number { const cardW = pageWidth - margin * 2; - const routeH = 30; - const trainRowH = 9; + // Tall enough to clear the time+date block below (topY + 22, see below) with a margin + // before the train sub-row's hairline — previously 30, which the date line (topY + 25) + // overran by several mm, printing "Wed, Jul 15" directly on top of the TRAIN row. + const routeH = 35; + const trainRowH = 8; const cardH = routeH + trainRowH; doc.setDrawColor(...HAIRLINE); @@ -241,37 +244,37 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n } const padX = 8; - const topY = y + (legLabel ? 12 : 8); + const topY = y + (legLabel ? 11 : 7); // Origin block label(doc, 'From', margin + padX, topY); doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); - doc.text(schedule.origin.code, margin + padX, topY + 8); + doc.text(schedule.origin.code, margin + padX, topY + 7); doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); - doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 13); + doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 11.5); const dep = new Date(schedule.departureAt); doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); - doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 20.5); + doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 18); doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); - doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 25); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 22); // Destination block (right-aligned) const dx = pageWidth - margin - padX; label(doc, 'To', dx, topY, { align: 'right' }); doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); - doc.text(schedule.destination.code, dx, topY + 8, { align: 'right' }); + doc.text(schedule.destination.code, dx, topY + 7, { align: 'right' }); doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); - doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 13, { align: 'right' }); + doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 11.5, { align: 'right' }); const arr = new Date(schedule.arrivalAt); doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); - doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 20.5, { align: 'right' }); + doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 18, { align: 'right' }); doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); - doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 25, { align: 'right' }); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 22, { align: 'right' }); // Dashed route line with endpoint markers, connecting the two blocks - const lineY = topY + 8.5; + const lineY = topY + 7.5; const lineX1 = margin + padX + 24; const lineX2 = dx - 24; doc.setDrawColor(...HAIRLINE); @@ -283,19 +286,20 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n doc.circle(lineX1, lineY, 0.9, 'F'); doc.circle(lineX2, lineY, 0.9, 'F'); - // Train info sub-row + // Train info sub-row — starts at routeH, comfortably below the date line above (topY + + // 22, i.e. y + 33 at most) so it never overlaps the journey block's text. const rowY = y + routeH; hairline(doc, margin, rowY, margin + cardW); doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); - doc.text('TRAIN', margin + padX, rowY + 6, { charSpace: 0.3 }); + doc.text('TRAIN', margin + padX, rowY + 5.5, { charSpace: 0.3 }); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold'); - doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 6); + doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 5.5); if (schedule.seatClass) { doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY); - doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 6, { align: 'right' }); + doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 5.5, { align: 'right' }); } - return y + cardH + 8; + return y + cardH + 6; } // ─── passenger details ───────────────────────────────────────────────────── @@ -316,7 +320,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, rows.push(['Seat', data.seatNumber || '—']); } - const rowH = 8; + const rowH = 7; rows.forEach(([k, v], i) => { const rowY = y + i * rowH; doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); @@ -345,7 +349,7 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100; doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); - return y + cardH + 8; + return y + cardH + 6; } // ─── instructions ────────────────────────────────────────────────────────── @@ -366,15 +370,18 @@ function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: numb doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11); doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15); - return y + cardH + 6; + return y + cardH + 4; } // ─── footer ──────────────────────────────────────────────────────────────── -function drawFooter(doc: jsPDF, createdAt: string): void { +function drawFooter(doc: jsPDF, createdAt: string, contentY: number): void { const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); - const footerY = pageHeight - 20; + // Pinned near the bottom for short (one-way) content, same as before — but grows past + // that floor instead of staying fixed when a round trip's extra journey card pushes + // content lower, which previously made the footer overlap the instructions card. + const footerY = Math.max(contentY + 4, pageHeight - 20); hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); @@ -395,7 +402,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW); label(doc, 'Journey details', margin, y); - y += 7; + y += 6; y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); if (data.isRoundTrip && data.inboundSchedule) { @@ -404,8 +411,8 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): y = drawPassengerDetails(doc, data, y, margin, pageW); y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits); - drawInstructions(doc, y, margin, pageW); - drawFooter(doc, data.createdAt); + y = drawInstructions(doc, y, margin, pageW); + drawFooter(doc, data.createdAt, y); } /** Generates and downloads one PDF voucher for a single passenger. */ From a2f234a1289023b383c9cd8fbe7ddf1218a3bf47 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 14 Jul 2026 00:14:54 +0300 Subject: [PATCH 05/33] Removed skip for now button from passenger information --- .../portal/src/app/booking/passengers/page.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 4639bd196..607548f13 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -993,7 +993,8 @@ function PassengersForm() { const onInvalid = () => { // Sections still behind the Fayda verify screen stay collapsed here — they only expand - // when the user explicitly clicks "Skip for now" / "Enter details manually". + // when the user explicitly clicks "Enter details manually" (shown only when Fayda is + // unavailable). setSubmitError('Please fix the highlighted errors before continuing.'); }; @@ -1153,14 +1154,6 @@ function PassengersForm() { Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first

)} -
) : showManualEntryLink ? (
From 1896c8d36c97fb5544606999652e6b585dec46b8 Mon Sep 17 00:00:00 2001 From: SennayT Date: Tue, 14 Jul 2026 00:15:24 +0300 Subject: [PATCH 06/33] remove skip for now button --- .../portal/src/app/booking/passengers/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 4639bd196..bfbb621ad 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -1153,14 +1153,14 @@ function PassengersForm() { Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first

)} - + */}
) : showManualEntryLink ? (
From 957a185a4de6a5dabefc34ecd31d1a07bc8e63f1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 13 Jul 2026 21:40:10 +0000 Subject: [PATCH 07/33] enhance contract clearance and train scheduling logic; add filters for clearance documents and improve booking validation --- .../contracts/contract-clearance.service.ts | 8 +++-- .../modules/contracts/contracts.repository.ts | 8 +++++ .../train-scheduling.service.ts | 29 ++++++++++++++++--- .../contracts/ExportClearanceStepper.tsx | 6 +++- .../bookings/DocumentClearanceDetailPage.tsx | 1 + 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 58f82856e..60eebf3da 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -848,8 +848,10 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract in phased clearance, - * including after booking is created. + * GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in + * phased clearance that already carry at least one uploaded clearance + * document — a contract still waiting for its first document has nothing to + * review, and GENERAL contracts clear per booking, not at contract level. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ @@ -857,6 +859,8 @@ export class ContractClearanceService { pageSize: filter.pageSize ?? 100, statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, + contractKind: 'ONE_TIME', + hasClearanceDocuments: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index be6316ade..3c9c7db14 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -26,6 +26,8 @@ export interface ContractListFilterOptions { tradeDirection?: string; paymentCurrency?: string; customsClearingEnabled?: boolean; + /** true → only contracts with at least one uploaded clearance document. */ + hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; } @@ -284,6 +286,12 @@ export class ContractsRepository extends BaseRepository { customsClearingEnabled: options.customsClearingEnabled, }); } + if (options.hasClearanceDocuments) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' + + 'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)', + ); + } if (options.serviceTypeId) { qb.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId: options.serviceTypeId, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c0aa14bd9..9e82bca33 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1136,14 +1136,35 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule has no train set'); } - // Batch parity: a schedule may only allocate bookings that targeted it. This mirrors - // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. + // Batch parity: a schedule may only allocate bookings from its route-day POOL. + // Under day-level pooling (see fillRouteDayInternal) an unreserved booking has + // a NULL train_schedule_id and is only pinned by reserve(); a reserved one is + // pinned to whichever train in the day's group first held it. Every train + // sharing this origin + destination + EAT departure day draws from ONE shared + // pool (one shared booking window), so a booking is allocatable here when it is + // either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking + // pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/ + // day/capacity fit is enforced downstream by validateBookingsForScheduling. + // EXPORT never groups, so its pool is this schedule alone (plus NULL pool). if (dto.bookingIds.length) { + const groupScheduleIds = new Set([scheduleId]); + if (schedule.direction !== 'EXPORT') { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + scheduleId, + ); + for (const sib of siblings) groupScheduleIds.add(sib.id); + } const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); - const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); + const stray = targeted.filter( + (b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId), + ); if (stray.length) { throw new BadRequestException( - `These bookings are not assigned to this schedule: ${stray + `These bookings are pinned to a train on a different route or day: ${stray .map((b) => b.reference ?? b.id) .join(', ')}`, ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index c94e005ae..df8871b07 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -149,8 +149,12 @@ export function ExportClearanceStepper({ const entityId = contractId ?? bookingId ?? ""; // The booking that carries the post-booking steps (gate pass, T1, invoice). const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + // Per-booking GENERAL clearance runs on a bare instance that only becomes a + // real booking once GL completes it — the caller's bookingCreated prop carries + // that signal, so a booking-keyed view must NOT count as "created" by itself + // (it would lock GL Ethiopia out of the declaration step right after the RO). const effectiveBookingCreated = - bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + bookingCreated || Boolean(clearance.linkedBookingId); const activeStep = useMemo( () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index bb271e205..79d35aa7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -248,6 +248,7 @@ export default function DocumentClearanceDetailPage() { // stepper's "Create booking" step must read as NOT-yet-created // so it never claims the booking is done before GL completes it. bookingCreated={Number(booking?.totalAmount ?? 0) > 0} + bookingMilestones={bookingMilestones ?? []} onChanged={() => void refetch()} onViewFile={view} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} From 0e18df6ad998e6a7bbfdcfcfb9c9dc62c0a8a6e4 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 14 Jul 2026 09:03:10 +0300 Subject: [PATCH 08/33] Updated sms text --- .../notifications/notifications.service.ts | 43 +++++++++++++++---- .../src/modules/tickets/tickets.service.ts | 18 ++++++-- .../src/app/booking/auth-check/page.tsx | 30 ++++++------- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index fc8bffa27..a1678d131 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -303,7 +303,7 @@ export class NotificationsService { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, }, }); @@ -353,6 +353,27 @@ export class NotificationsService { } } + /** + * Resolves the user's actual boarding/alighting stations from the booking's originStationId / + * destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when + * the booking has no segment override (e.g. older records or packages). + */ + private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } { + const s = booking?.schedule ?? {}; + const stopTimes: any[] = s.stopTimes ?? []; + const findStation = (stationId: string | null | undefined, fallback: any) => { + if (stationId && stopTimes.length > 0) { + const stop = stopTimes.find((st: any) => st.stationId === stationId); + if (stop?.station) return stop.station; + } + return fallback ?? null; + }; + return { + originStation: findStation(booking?.originStationId, s.originStation), + destinationStation: findStation(booking?.destinationStationId, s.destinationStation), + }; + } + /** * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get @@ -379,12 +400,13 @@ export class NotificationsService { // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. const passengerName = seats[0]?.passengerName ?? 'Passenger'; const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; + const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return { passengerName, bookingRef: ref, - origin: s.originStation?.name ?? '', - destination: s.destinationStation?.name ?? '', + origin: originSt?.name ?? '', + destination: destSt?.name ?? '', trainSeatLines, travelDate: fmtDate(s.departureAt), departureTime: fmtTime(s.departureAt), @@ -406,7 +428,7 @@ export class NotificationsService { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, }, }); @@ -489,9 +511,10 @@ export class NotificationsService { const s = booking.schedule ?? {}; const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); + const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return [ `Booking ${booking.bookingRef} confirmed.`, - `${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`, + `${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`, `Train: ${s.train?.name ?? s.train?.number ?? ''}`, `Departs: ${dep}`, passengers ? `Passengers: ${passengers}` : '', @@ -504,6 +527,7 @@ export class NotificationsService { const s = booking.schedule ?? {}; const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; + const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); const seatRows = (booking.seats ?? []) .map((bs: any) => { const coach = bs.seat?.coach?.number ?? '-'; @@ -532,11 +556,11 @@ export class NotificationsService { - + - + @@ -612,8 +636,9 @@ export class NotificationsService { const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; - const origin = s.originStation?.name ?? ''; - const dest = s.destinationStation?.name ?? ''; + const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const origin = originSt?.name ?? ''; + const dest = destSt?.name ?? ''; const train = s.train?.name ?? s.train?.number ?? ''; const dep = fmt(s.departureAt); const arr = fmt(s.arrivalAt); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 1a9c88d4e..656e726ab 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -386,7 +386,7 @@ export class TicketsService { let booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, tickets: true, seats: { include: { seat: { include: { coach: true } } } }, @@ -400,7 +400,7 @@ export class TicketsService { booking = await this.prisma.booking.findUnique({ where: { bookingRef: ticket.bookingRef }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, tickets: true, seats: { include: { seat: { include: { coach: true } } } }, @@ -453,6 +453,18 @@ export class TicketsService { // Send notifications after successful boarding await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND'); + // Resolve user-selected segment rather than the full schedule route + const _schedStops = (booking as any).schedule?.stopTimes ?? []; + const _resolveStation = (id: string | null | undefined, fallback: any) => { + if (id) { + const found = _schedStops.find((st: any) => st.stationId === id)?.station; + if (found) return found; + } + return fallback; + }; + const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation); + const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation); + return { success: true, message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, @@ -461,7 +473,7 @@ export class TicketsService { ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', - route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`, + route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`, seat: seatNumber, coach: coachNumber, trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A', diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index 43775b1c7..9e1cdc9c9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -81,20 +81,6 @@ export default function AuthCheckPage() {

- - - - + + + +
From 35eb4b37ac5d8edd6824d2f41b95b9c0102144a9 Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Tue, 14 Jul 2026 09:06:43 +0300 Subject: [PATCH 09/33] feat(seo): Add metadata and SEO improvements to passenger portal --- .github/workflows/deploy.yml | 1 - .../portal/src/app/about/layout.tsx | 21 ++++ .../portal/src/app/contact/layout.tsx | 21 ++++ .../portal/src/app/guide/layout.tsx | 28 ++++++ .../portal/src/app/help/layout.tsx | 23 +++++ .../portal/src/app/layout.tsx | 97 ++++++++++++++++++- .../portal/src/app/packages/[id]/layout.tsx | 80 +++++++++++++++ .../portal/src/app/packages/layout.tsx | 23 +++++ .../edr-passenger-web/portal/src/app/page.tsx | 17 ++++ .../portal/src/app/robots.ts | 37 +++++++ .../portal/src/app/services/layout.tsx | 23 +++++ .../portal/src/app/sitemap.ts | 59 +++++++++++ .../portal/src/components/JsonLd.tsx | 12 +++ .../portal/src/middleware.ts | 27 ++++-- 14 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/about/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/contact/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/guide/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/help/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/packages/[id]/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/packages/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/robots.ts create mode 100644 apps/edr-passenger-web/portal/src/app/services/layout.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/sitemap.ts create mode 100644 apps/edr-passenger-web/portal/src/components/JsonLd.tsx diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 057cb0c1b..f0beccd20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,6 @@ name: Deploy Stacks on: push: branches: - - main - dev - staging workflow_dispatch: diff --git a/apps/edr-passenger-web/portal/src/app/about/layout.tsx b/apps/edr-passenger-web/portal/src/app/about/layout.tsx new file mode 100644 index 000000000..a7bb1b25d --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/about/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'About EDR – Connecting East Africa by Rail', + description: + 'Learn about the Ethio-Djibouti Railway (EDR): our mission to connect Ethiopia and Djibouti with comfortable, affordable, and sustainable train travel.', + alternates: { + canonical: '/about', + }, + openGraph: { + title: 'About EDR – Connecting East Africa by Rail', + description: + 'Our mission is to provide reliable, affordable, and comfortable train travel connecting Ethiopia and Djibouti.', + url: '/about', + images: [{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway – About Us' }], + }, +}; + +export default function AboutLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/edr-passenger-web/portal/src/app/contact/layout.tsx b/apps/edr-passenger-web/portal/src/app/contact/layout.tsx new file mode 100644 index 000000000..3a5c61836 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/contact/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Contact EDR – Get in Touch', + description: + 'Contact the Ethio-Djibouti Railway (EDR) support team. We are available to assist with your train journey.', + alternates: { + canonical: '/contact', + }, + openGraph: { + title: 'Contact EDR – Get in Touch', + description: + 'Reach the EDR support team by phone, email, or in person. We are available to assist with your train journey.', + url: '/contact', + images: [{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway – Contact Us' }], + }, +}; + +export default function ContactLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/edr-passenger-web/portal/src/app/guide/layout.tsx b/apps/edr-passenger-web/portal/src/app/guide/layout.tsx new file mode 100644 index 000000000..417c46cf3 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/guide/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'How to Book a Train Ticket – EDR Step-by-Step Guide', + description: + 'Step-by-step guide to booking an EDR train ticket online: search trains, select your seat class, enter passenger details, and complete payment.', + alternates: { + canonical: '/guide', + }, + openGraph: { + title: 'How to Book a Train Ticket – EDR Step-by-Step Guide', + description: + 'Follow our simple guide to book your EDR train journey in minutes.', + url: '/guide', + images: [ + { + url: '/edr-banner.jpg', + width: 1200, + height: 630, + alt: 'EDR Railway – How to Book a Train Ticket', + }, + ], + }, +}; + +export default function GuideLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/edr-passenger-web/portal/src/app/help/layout.tsx b/apps/edr-passenger-web/portal/src/app/help/layout.tsx new file mode 100644 index 000000000..0211d6d23 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/help/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Help & FAQs – EDR Train Booking', + description: + 'Find answers to common questions about booking EDR train tickets: how to book, passenger pricing, identity verification, seat classes, payment methods, and more.', + alternates: { + canonical: '/help', + }, + openGraph: { + title: 'Help & FAQs – EDR Train Booking', + description: + 'Answers to your questions about booking EDR train tickets, payment methods, identity verification, and seat selection.', + url: '/help', + images: [ + { url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway – Help & FAQs' }, + ], + }, +}; + +export default function HelpLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 1128124e3..a6d5c787f 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -7,10 +7,61 @@ import MobileTopBar from '@/components/MobileTopBar'; import BottomTabBar from '@/components/BottomTabBar'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import SupportWidget from '@/features/support/SupportWidgetLazy'; +import { JsonLd } from '@/components/JsonLd'; + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://passenger.edrsc.com'; export const metadata: Metadata = { - title: 'EDR Passenger Portal - Book your train journey', - description: 'Book train tickets on the Ethio-Djibouti Railway', + metadataBase: new URL(siteUrl), + title: { + default: 'EDR Passenger Portal – Book Train Tickets Online', + template: '%s | EDR Passenger Portal', + }, + description: + 'Book train tickets on the Ethio-Djibouti Railway. Travel between Ethiopia and Djibouti with easy online booking, seat selection, and multiple payment options.', + authors: [{ name: 'EDR – Ethio-Djibouti Railway' }], + creator: 'EDR – Ethio-Djibouti Railway', + publisher: 'EDR – Ethio-Djibouti Railway', + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-snippet': -1, + 'max-image-preview': 'large', + 'max-video-preview': -1, + }, + }, + openGraph: { + type: 'website', + locale: 'en_US', + url: siteUrl, + siteName: 'EDR Passenger Portal', + title: 'EDR Passenger Portal – Book Train Tickets Online', + description: + 'Book train tickets on the Ethio-Djibouti Railway. Comfortable seats, flexible payment options, and easy online booking.', + images: [ + { + url: '/edr-banner.jpg', + width: 1200, + height: 630, + alt: 'EDR Ethio-Djibouti Railway – Book your train journey', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'EDR Passenger Portal – Book Train Tickets Online', + description: + 'Book train tickets on the Ethio-Djibouti Railway. Comfortable seats, flexible payment options, and easy online booking.', + images: ['/edr-banner.jpg'], + }, + icons: { + icon: '/edr-logo.png', + shortcut: '/edr-logo.png', + apple: '/edr-logo.png', + }, }; export default function RootLayout({ @@ -20,8 +71,48 @@ export default function RootLayout({ }) { // Nonce set per-request by middleware; required for this inline script under the CSP. const nonce = headers().get('x-nonce') ?? undefined; + + const organizationSchema = { + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Ethio-Djibouti Railway (EDR)', + url: siteUrl, + logo: `${siteUrl}/edr-logo.png`, + contactPoint: { + '@type': 'ContactPoint', + telephone: '9546', + contactType: 'customer service', + availableLanguage: ['English', 'Amharic'], + }, + address: { + '@type': 'PostalAddress', + addressLocality: 'Addis Ababa', + addressCountry: 'ET', + }, + sameAs: [], + }; + + const websiteSchema = { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'EDR Passenger Portal', + url: siteUrl, + potentialAction: { + '@type': 'SearchAction', + target: { + '@type': 'EntryPoint', + urlTemplate: `${siteUrl}/booking/search?q={search_term_string}`, + }, + 'query-input': 'required name=search_term_string', + }, + }; + return ( - + + + + +
From${s.originStation?.name ?? ''} (${s.originStation?.code ?? ''})${originSt?.name ?? ''} (${originSt?.code ?? ''})
To${s.destinationStation?.name ?? ''} (${s.destinationStation?.code ?? ''})${destSt?.name ?? ''} (${destSt?.code ?? ''})
Train