diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index f5d71229d..e2e90be05 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -119,10 +119,13 @@ export default function ConfirmationPage() { const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; // The server-confirmed settled amount/currency (what was actually charged) is - // authoritative — prefer it over the ETB booking fare once it's available. + // authoritative — prefer it over the ETB booking fare once available. Shown exactly + // as returned by the API (no /100, no per-passenger split) on every passenger's + // voucher — see fareIsMajorUnits below. const settledAmountMinor = _booking?.payment?.amountMinor; const settledCurrency = _booking?.payment?.currency; - const voucherCurrency = settledCurrency || "ETB"; + const hasSettledAmount = settledAmountMinor != null && !!settledCurrency; + const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB"; const createdAt = _booking?.createdAt || new Date().toISOString(); const status = _booking?.status || "CONFIRMED"; @@ -154,21 +157,6 @@ export default function ConfirmationPage() { return Math.round(totalFare / passengers.length); }; - // Real conversion happened (payment settled in something other than ETB) — scale each - // passenger's ETB fare proportionally into the settled currency, rather than showing - // ETB-denominated numbers next to a foreign currency label. - const etbFares = passengers.map((_, idx) => getEtbFare(idx)); - const etbTotal = etbFares.reduce((sum, f) => sum + f, 0); - const needsConversion = - settledAmountMinor != null && - settledCurrency && - settledCurrency !== "ETB" && - etbTotal > 0; - const getVoucherFare = (idx: number): number => { - if (!needsConversion) return etbFares[idx]; - return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal)); - }; - const outbound = { trainNumber: activeSchedule?.trainNumber || "N/A", trainName: "EDR Express", @@ -234,8 +222,9 @@ export default function ConfirmationPage() { outboundSchedule: outbound, inboundSchedule: inbound, isRoundTrip, - fareMinor: getVoucherFare(i), + fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), currency: voucherCurrency, + fareIsMajorUnits: hasSettledAmount, createdAt, }); } 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 677fa2497..7aef411e6 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 @@ -18,6 +18,7 @@ import { Smartphone, Loader2, ChevronLeft, + X, } from "lucide-react"; import { format } from "date-fns"; import { formatTime, getTimePeriod } from "@/utils/format"; @@ -53,6 +54,11 @@ function BookingDetailContent() { string | null >(null); const [paymentError, setPaymentError] = useState(null); + // The mobile Pay trigger opens this modal instead of living in a `fixed bottom-0` + // bar — that bar kept getting covered by the phone's own home-indicator/gesture + // nav bar. The modal's footer button is a normal in-flow flex item instead, so it + // can't end up pinned underneath system chrome. + const [paymentModalOpen, setPaymentModalOpen] = useState(false); const [copiedPNR, setCopiedPNR] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); @@ -486,7 +492,7 @@ function BookingDetailContent() { if (isPendingPayment && !isExpired) { return ( -
+

@@ -676,6 +682,11 @@ function BookingDetailContent() { setSelectedMethodCurrency( method.currency ?? null, ); + // Mobile only — desktop's Pay button lives inline in the + // sidebar OrderSummary, not behind a modal. + if (window.innerWidth < 1024) { + setPaymentModalOpen(true); + } }} disabled={ paymentMutation.isPending || @@ -735,57 +746,93 @@ function BookingDetailContent() {

- {/* Mobile sticky bottom bar */} -
-
- - Total - - - {awaitingAmount ? ( - - ) : ( - <> - {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} - - )} - + {/* Mobile payment dialog — the amount + Back/Pay buttons live here now, + opened by picking a payment method above. Centered dialog-box style + (not a full-screen sheet) so it can't end up pinned under system chrome. */} + {paymentModalOpen && ( +
+ {/* Backdrop */} +
setPaymentModalOpen(false)} + /> + + {/* Dialog */} +
+
+

+ Confirm payment +

+ +
+ +
+
+ + Total + + + {awaitingAmount ? ( + + ) : ( + <> + {confirmedCurrency}{" "} + {(totalAmountDisplay ?? 0).toFixed(2)} + + )} + +
+

+ {selectedPaymentMethod + ? `via ${selectedPaymentMethod.displayName}` + : "Select a payment method to continue."} +

+ {paymentError && ( +

+ ⚠️ {paymentError} +

+ )} +
+ +
+ +
+
- {paymentError && ( -

- ⚠️ {paymentError} -

- )} -
- - -
-
+ )}
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6ce8b5f66..67d150bc4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -372,7 +372,7 @@ export default function PaymentPage() { ); return ( -
+

Complete payment

@@ -487,7 +487,7 @@ export default function PaymentPage() {
{/* Mobile sticky bottom bar */} -
+
Total diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 30e5393f9..c269f4161 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -25,14 +25,16 @@ import { useEffect, useRef, useState, useCallback } from "react"; import ModernDatePicker from "@/components/ModernDatePicker"; function useDarkMode() { - const [dark, setDark] = useState(() => - typeof window !== 'undefined' && document.documentElement.classList.contains('dark') + const [dark, setDark] = useState( + () => + typeof window !== "undefined" && + document.documentElement.classList.contains("dark"), ); useEffect(() => { const obs = new MutationObserver(() => - setDark(document.documentElement.classList.contains('dark')) + setDark(document.documentElement.classList.contains("dark")), ); - obs.observe(document.documentElement, { attributeFilter: ['class'] }); + obs.observe(document.documentElement, { attributeFilter: ["class"] }); return () => obs.disconnect(); }, []); return dark; @@ -344,7 +346,9 @@ function PassengerModal({ {nationalityError && (

{nationalityError}

)} -
+
{natOptions.map((opt) => ( + +
+
+ ); + + // From/To fields — rendered both in the mobile collapsed view (so a station can be + // picked without opening the full modal) and inside the modal itself (prefilled with + // whatever was already picked). Tapping either still opens StationModal directly. + // stacked: single column for the collapsed first view; the modal keeps the 2-column grid. + const renderStationFields = (stacked = false) => ( +
+
+
+ +
+ + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )} +
+
+
+ + +
+ + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )} +
+
+ ); return (
@@ -765,7 +935,9 @@ export default function SearchPage() { clearErrors("nationality"); }} onClose={() => setPassengerModalOpen(false)} - nationalityError={showNationalityError ? errors.nationality?.message : undefined} + nationalityError={ + showNationalityError ? errors.nationality?.message : undefined + } /> )} @@ -870,241 +1042,182 @@ export default function SearchPage() { )}
- {/* Trip Type Tabs */} -
-
- - -
+ {/* Trip Type Tabs — always visible on desktop; on mobile only inside the + search modal (hidden in the collapsed view). */} +
+ {renderTripTypeTabs()}
- {/* Mobile: stacked, but From/To and Date/Return Date pair up into two - columns each to save vertical space (station names/dates truncate - rather than wrap) — same fields, same behavior, just denser. */} -
-
-
-
- -
- - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} -
-
-
- - -
- - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
-
-
-
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); - }} - minDate={new Date()} - placeholder="Departure date" - error={!!errors.departureDate} - /> -
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )} -
- {tripType === "ROUND_TRIP" && ( -
- -
- { - setValue( - "returnDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("returnDate"); - }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Return date" - error={!!errors.returnDate} - /> -
- {errors.returnDate && ( -

- {errors.returnDate.message} -

- )} -
- )} -
- {/* Pax + Nationality combined trigger */} + {/* Mobile collapsed view — just From/To + Search. Tapping a station still + opens StationModal directly; tapping Search opens the full modal below + instead of submitting (validation happens inside that modal). */} +
+ {renderStationFields(true)} - {showNationalityError && ( -

{errors.nationality?.message}

- )} -
+ {/* Mobile search modal — full field set (trip type, From/To, dates, + passengers/nationality). Its Search button is the real form submit — + same onSubmit/onInvalid/validation as before, just relocated here. */} + {mobileSearchModalOpen && ( +
+
+ +

+ Search Trains +

+
+ +
+ {renderTripTypeTabs()} + {renderStationFields()} +
+
+ +
+ { + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Departure date" + error={!!errors.departureDate} + /> +
+ {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )} +
+ {tripType === "ROUND_TRIP" && ( +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Return date" + error={!!errors.returnDate} + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )} +
+ )} +
+ {/* Pax + Nationality combined trigger */} + + {showNationalityError && ( +

+ {errors.nationality?.message} +

+ )} + +
+
+ )} + {/* Desktop: dynamic layout based on trip type */}
{tripType === "ONE_WAY" ? ( @@ -1219,12 +1332,17 @@ export default function SearchPage() { type="button" onClick={() => setPassengerModalOpen(true)} className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${ - showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700" + showNationalityError + ? "border-red-400" + : "border-gray-200 dark:border-gray-700" }`} > - {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} + {totalPassengers}{" "} + {totalPassengers === 1 + ? "Passenger" + : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"} @@ -1232,7 +1350,9 @@ export default function SearchPage() { {showNationalityError && ( -

{errors.nationality?.message}

+

+ {errors.nationality?.message} +

)}
{/* Search */} @@ -1259,7 +1379,9 @@ export default function SearchPage() {
{/* From */}
- + {hasInteracted && errors.originStationId && ( -

{errors.originStationId.message}

+

+ {errors.originStationId.message} +

)}
{/* Swap */} @@ -1291,7 +1419,9 @@ export default function SearchPage() { {/* To */}
- + {hasInteracted && errors.destinationStationId && ( -

{errors.destinationStationId.message}

+

+ {errors.destinationStationId.message} +

)}
{/* Departure Date */}
- + { - setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); trigger("departureDate"); trigger("returnDate"); }} @@ -1325,44 +1470,72 @@ export default function SearchPage() { placeholder="Departure date" /> {errors.departureDate && ( -

{errors.departureDate.message}

+

+ {errors.departureDate.message} +

)}
{/* Return Date */}
- + { - setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); trigger("returnDate"); }} - minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } placeholder="Return date" /> {errors.returnDate && ( -

{errors.returnDate.message}

+

+ {errors.returnDate.message} +

)}
{/* Passengers */}
- + {showNationalityError && ( -

{errors.nationality?.message}

+

+ {errors.nationality?.message} +

)}
{/* Search */} @@ -1386,7 +1559,6 @@ export default function SearchPage() {
)}
-
diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 1128124e3..7e6049956 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { headers } from 'next/headers'; import './globals.css'; import { Providers } from './providers'; @@ -13,6 +13,15 @@ export const metadata: Metadata = { description: 'Book train tickets on the Ethio-Djibouti Railway', }; +// viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay +// button) read env(safe-area-inset-bottom) so they pad above the home +// indicator / gesture nav bar instead of being covered by it. +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', +}; + export default function RootLayout({ children, }: { 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 7fd0e71f2..4c8f25eec 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -27,6 +27,10 @@ interface PassengerVoucherData { fareMinor: number; currency: string; createdAt: string; + // True when fareMinor is already a display-ready amount (e.g. the settled + // payment.amountMinor straight from the API) and must NOT be divided by 100 — as + // opposed to the normal case where fareMinor is genuine minor units (cents). + fareIsMajorUnits?: boolean; } // ─── palette ─────────────────────────────────────────────────────────────── @@ -327,7 +331,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, // ─── fare summary ────────────────────────────────────────────────────────── -function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { +function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number, fareIsMajorUnits = false): number { const cardH = 20; doc.setFillColor(...BRAND_SOFT); doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F'); @@ -338,7 +342,8 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num doc.text('✓ PAID', margin + padX, y + 15); doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); - doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); + const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100; + doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); return y + cardH + 8; } @@ -398,7 +403,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): } y = drawPassengerDetails(doc, data, y, margin, pageW); - y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); + y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt); } @@ -432,11 +437,12 @@ 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. const settledAmountMinor = booking.payment?.amountMinor; const settledCurrency = booking.payment?.currency; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency; - const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor; // 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 @@ -459,8 +465,9 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => status: booking.status, outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, isRoundTrip: false, - fareMinor: Math.round(totalForSplit / booking.passengers.length), + fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length), currency: voucherCurrency, + fareIsMajorUnits: useSettledAmount, createdAt: booking.createdAt, }); }