From c852f99a3ae9f3093ae0584b87b38e935753d01f Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 6 Jul 2026 03:15:43 +0300 Subject: [PATCH 1/2] Update passenger contact information --- .../src/app/booking/passengers/page.tsx | 181 ++++++++++++------ 1 file changed, 120 insertions(+), 61 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 d5857ac98..e2ca159b5 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 @@ -529,6 +529,10 @@ const passengerSchema = z.object({ dateOfBirth: z.string().min(1, 'Date of birth is required'), gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), + // Adults enter these themselves; children inherit the primary adult's values (see the + // sync effect in the page component) rather than collecting their own. + phone: z.string(), + email: z.string(), nationalId: z.string().optional(), passportNumber: z.string().optional(), passportCountry: z.string().optional(), @@ -557,27 +561,29 @@ function createFormSchema(adultCount: number) { return z.object({ passengers: z.array(passengerSchema), createAccount: z.boolean(), - contactPhone: z.string(), - contactEmail: z.string(), }).superRefine((data, ctx) => { - if (!data.contactEmail || data.contactEmail.trim().length === 0) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Contact email is required', path: ['contactEmail'] }); - } else { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(data.contactEmail)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['contactEmail'] }); - } - } - const contactNationality = data.passengers[0]?.nationality || 'ETHIOPIAN'; - const phoneError = validatePhone(data.contactPhone, contactNationality); - if (phoneError) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['contactPhone'] }); - } - data.passengers.forEach((p, i) => { + const isAdult = i < adultCount; + + // Contact fields are only collected from — and validated against — adults. + // Children's phone/email are inherited from the primary adult, not user-entered. + if (isAdult) { + if (!p.email || p.email.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required', path: ['passengers', i, 'email'] }); + } else { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(p.email)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] }); + } + } + const phoneError = validatePhone(p.phone, p.nationality); + if (phoneError) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['passengers', i, 'phone'] }); + } + } + const age = calculateAge(p.dateOfBirth); if (age === null) return; - const isAdult = i < adultCount; if (isAdult && age <= 5) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] }); } else if (!isAdult && age > 5) { @@ -623,6 +629,8 @@ export default function PassengersPage() { dateOfBirth: stored.dateOfBirth || '', gender: (stored.gender as any) || undefined, nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN', + phone: (i >= adultCount ? storedPassengers[0]?.phone : stored.phone) || '', + email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '', nationalId: stored.nationalId || '', passportNumber: stored.passportNumber || '', passportCountry: stored.passportCountry || '', @@ -638,6 +646,10 @@ export default function PassengersPage() { dateOfBirth: '', gender: undefined, nationality: searchCriteria?.nationality || 'ETHIOPIAN', + // Children start out mirroring whatever the primary adult already has on file; + // the sync effect below keeps this current as the primary adult's info changes. + phone: (i >= adultCount ? storedPassengers[0]?.phone : '') || '', + email: (i >= adultCount ? storedPassengers[0]?.email : '') || '', nationalId: '', passportNumber: '', passportCountry: '', @@ -649,14 +661,28 @@ export default function PassengersPage() { }; }), createAccount: false, - contactPhone: storedPassengers[0]?.phone || '', - contactEmail: storedPassengers[0]?.email || '', }, }); const { fields } = useFieldArray({ control, name: 'passengers' }); const passengers = watch('passengers'); + // Children don't collect their own contact info — keep their phone/email mirrored to + // whatever the primary adult (index 0) currently has, so it's always in sync. + const primaryPhone = passengers[0]?.phone; + const primaryEmail = passengers[0]?.email; + useEffect(() => { + for (let i = adultCount; i < passengers.length; i++) { + if (passengers[i]?.phone !== (primaryPhone || '')) { + setValue(`passengers.${i}.phone`, primaryPhone || '', { shouldValidate: true }); + } + if (passengers[i]?.email !== (primaryEmail || '')) { + setValue(`passengers.${i}.email`, primaryEmail || '', { shouldValidate: true }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [primaryPhone, primaryEmail, adultCount, passengers.length]); + useEffect(() => { const checkFaydaStatus = async () => { try { @@ -719,10 +745,9 @@ export default function PassengersPage() { const normalizedGender = normalizeFaydaGender(d.gender); if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true }); if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub); - // Contact info is shared across all passengers — only fill it in if nobody has - // entered it yet, so verifying passenger 2 can't clobber passenger 1's contact. - if (d.email && !watch('contactEmail')) setValue('contactEmail', d.email, { shouldValidate: true }); - if (d.phoneNumber && !watch('contactPhone')) setValue('contactPhone', d.phoneNumber, { shouldValidate: true }); + // Only fill in this passenger's own contact fields if they haven't entered them yet. + if (d.email && !watch(`passengers.${targetIndex}.email`)) setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true }); + if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true }); setValue(`passengers.${targetIndex}.faydaVerified`, true); setValue(`passengers.${targetIndex}.formExpanded`, true); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' })); @@ -781,8 +806,8 @@ export default function PassengersPage() { setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); - if (passengerData?.phone || user.phone) setValue('contactPhone', passengerData?.phone || user.phone || ''); - if (passengerData?.email || user.email) setValue('contactEmail', passengerData?.email || user.email || ''); + if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); + if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry); if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); @@ -935,8 +960,8 @@ export default function PassengersPage() { passportIssueDate: p.passportIssueDate, passportExpiryDate: p.passportExpiryDate, passportIssuingAuthority: p.passportIssuingAuthority, - phone: data.contactPhone, - email: data.contactEmail, + phone: p.phone, + email: p.email, isPrimaryPassenger: i === 0, passengerId: i === 0 && passengerId ? passengerId : undefined, })) @@ -1155,6 +1180,40 @@ export default function PassengersPage() { disabled /> + + {isChildPassenger ? ( +
+ Contact details (phone & email) are shared with the primary passenger and don't need to be entered separately. +
+ ) : ( + <> + {/* Phone */} +
+ + setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} + /> +
+ + {/* Email */} +
+ + + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )} +
+ + )} ) : ( @@ -1210,6 +1269,40 @@ export default function PassengersPage() { disabled /> + + {isChildPassenger ? ( +
+ Contact details (phone & email) are shared with the primary passenger and don't need to be entered separately. +
+ ) : ( + <> + {/* Phone */} +
+ + setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} + /> +
+ + {/* Email */} +
+ + + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )} +
+ + )} {/* Passport fields */} @@ -1267,40 +1360,6 @@ export default function PassengersPage() { ); })} -
-

Contact Information

-

- This phone number and email will be used for booking and ticketing communication for all passengers. -

-
- {/* Contact Phone */} -
- - setValue('contactPhone', v)} - onNormalized={(v) => setValue('contactPhone', v, { shouldValidate: true })} - error={errors.contactPhone?.message} - /> -
- - {/* Contact Email */} -
- - - {errors.contactEmail && ( -

{errors.contactEmail.message}

- )} -
-
-
- {!isAuthenticated && (
{/* PNR Card */} -
+
-

Booking reference (PNR)

+

Booking reference (PNR)

{pnr}
-

Save this reference number for future use

+

Save this reference number for future use

@@ -330,7 +330,7 @@ export default function ConfirmationPage() {

Total paid

- ETB {((_booking?.totalMinor || passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)} + {paidAmountMinor != null ? paidCurrency : 'ETB'} {((paidAmountMinor ?? _booking?.totalMinor ?? passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)}

@@ -395,50 +395,24 @@ export default function ConfirmationPage() { {/* Action Buttons */} -
- - - - -
{/* New Booking Button */} 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 c6122dda4..e443ccf07 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 @@ -901,7 +901,7 @@ export default function PassengersPage() { } } catch (error) { setVerificationStatus((prev) => ({ ...prev, [index]: 'error' })); - setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' })); + setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again.' })); } finally { setVerifyingIndex(null); clearPendingFaydaIndex(); 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 c2c32a8cf..468058fe1 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 @@ -29,7 +29,7 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore(); - const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); + const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); @@ -43,11 +43,6 @@ export default function PaymentPage() { const displayCurrency = 'ETB' as const; - // Keep payment store in sync so the mutation picks up the right currency. - useEffect(() => { - setCurrency(displayCurrency); - }, [displayCurrency, setCurrency]); - const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ queryKey: ['paymentMethods', displayCurrency], queryFn: async () => { @@ -77,27 +72,52 @@ export default function PaymentPage() { const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; + // Prefer each passenger's own seat fare (set during seat selection) over the schedule's + // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a + // single schedule-level fare can't correctly represent every passenger's actual seat. const outboundBaseFare = isPackage ? pkgPerLegTotal - : (isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0), 0) : 0); + : (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => { + const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0); + return sum + calculatePassengerFare(passengers, i, fare); + }, 0) : 0); const inboundBaseFare = isPackage ? pkgPerLegTotal - : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 0) : 0); + : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, p, i) => { + const fare = (p as any).inboundSeatFareMinor ?? (inboundSchedule.baseFareAdult || 0); + return sum + calculatePassengerFare(passengers, i, fare); + }, 0) : 0); const baseFare = isPackage ? adultCount * pkgAdultFare + childCount * pkgChildFare - : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => { - const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; + : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { + const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; + const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare; return sum + calculatePassengerFare(passengers, i, farePerPassenger); }, 0); - // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency + // Amount to show on screen: /payments/booking-amount already returns a ready-to-display + // major-unit amount, so render it directly instead of round-tripping it through minor + // units and back (× 100 to convert, ÷ 100 again to display). + const totalAmountDisplay = bookingAmountData != null ? bookingAmountData.amount : baseFare / 100; + + // Minor-unit form, kept only for the actual charge request and for persisting the + // confirmed amount — the rest of the app's fare fields (baseFareMinor, fareMinor, etc.) + // are minor-unit based, so this keeps that convention internally without affecting display. const totalAmount = bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare; const confirmedCurrency = bookingAmountData?.currency || amountCurrency; + // Persist the amount/currency actually confirmed for the selected payment option so + // downstream screens (e.g. the voucher) use it instead of a default ETB fare. + useEffect(() => { + if (bookingAmountData == null) return; + setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); + setPaidAmount(totalAmount); + }, [bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -277,9 +297,11 @@ export default function PaymentPage() { if (isPackage) { passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare; } else { - const outFare = outboundSchedule?.baseFareAdult || 0; - const inFare = inboundSchedule?.baseFareAdult || 0; - const onewayFare = selectedSchedule?.baseFareAdult || 0; + // Prefer this passenger's actual seat fare (varies by berth for bed coaches) + // over the schedule's flat baseFareAdult. + const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); + const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0); + const onewayFare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0); const outboundFare = calculatePassengerFare(passengers, i, outFare); const inboundFare = calculatePassengerFare(passengers, i, inFare); const oneWayFare = calculatePassengerFare(passengers, i, onewayFare); @@ -311,7 +333,7 @@ export default function PaymentPage() { {formatFare( isPackage ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) - : calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), + : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)), displayCurrency )} @@ -320,7 +342,7 @@ export default function PaymentPage() { {formatFare( isPackage ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) - : calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), + : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)), displayCurrency )} @@ -338,7 +360,7 @@ export default function PaymentPage() { {loadingAmount && ( )} - {confirmedCurrency} {(totalAmount / 100).toFixed(2)} + {confirmedCurrency} {totalAmountDisplay.toFixed(2)} @@ -362,7 +384,7 @@ export default function PaymentPage() { Calculating amount... ) : ( - `Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}` + `Pay ${confirmedCurrency} ${totalAmountDisplay.toFixed(2)}` )} 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 6a54fe010..b94b9ee14 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 @@ -199,6 +199,9 @@ export default function ResultsPage() { selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: 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 || [], }; // For round trip, store outbound and advance to inbound step @@ -232,7 +235,8 @@ export default function ResultsPage() { const scheduleId = classModal.scheduleId || classModal.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; const isOutbound = (classModal as any).isOutbound; - const coachTypes = classModal.coachTypes || []; + // Dining coaches aren't bookable seat/bed classes — exclude them from selection. + const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC'); const getCoachIcon = (typeName: string) => { const lower = typeName.toLowerCase(); diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 862a0ba8f..359da2236 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -393,14 +393,24 @@ export default function ReviewPage() { const scheduleSeatClassName = isRoundTrip ? (outboundSchedule as any)?.seatClassName : (selectedSchedule as any)?.seatClassName; - const seatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id; - if (!seatClassId) return; + const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id; + if (!fallbackSeatClassId) return; + + // Bed coaches price Upper/Middle/Lower as separate classes, so a passenger's own + // assigned berth (captured on the seats page) must resolve its own seatClassId here + // — a single shared class can't correctly price passengers in different berths. + const resolveSeatClassId = (p: any): string => { + const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition; + if (!bedPosition) return fallbackSeatClassId; + const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition)); + return match?.id || fallbackSeatClassId; + }; const passengersParam = JSON.stringify( passengers.map(p => ({ passengerName: p.name, dateOfBirth: p.dateOfBirth, - seatClassId, + seatClassId: resolveSeatClassId(p), nationality: p.nationality, })) ); @@ -444,9 +454,28 @@ export default function ReviewPage() { const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + // Per-seat fare captured on the seats page (bed-position-aware, computed locally from + // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the + // backend /search/fare-breakdown call whose seatClassId matching for bed positions can't + // be verified here. Prefer it whenever the passenger actually has an assigned seat. + const getPassengerSeatFare = (p: any): number | null => { + if (isRoundTrip) { + if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null; + return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0); + } + return p.seatFareMinor ?? null; + }; + const total = isPackageBooking ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare - : (fareBreakdown?.totalMinor ?? 0); + : passengers.reduce((sum, p, i) => { + const isChildPassenger = isChild(p); + const line = fareBreakdown?.passengers?.[i]; + const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)); + if (isFreeChild) return sum; + const seatFare = getPassengerSeatFare(p); + return sum + (seatFare ?? line?.fareMinor ?? 0); + }, 0); // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => ( @@ -457,10 +486,11 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; const isChildPassenger = isChild(p); + const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); + const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isChildPassenger ? pkgChildFare : pkgAdultFare) - : (line?.fareMinor ?? 0); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); + : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index f0e765f33..b1987f588 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -6,11 +6,12 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { useState, useEffect, useCallback, useMemo, memo } from "react"; -import { Armchair, Bed, ChevronLeft, ChevronDown } from "lucide-react"; +import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; +import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; import Image from "next/image"; import CustomModal from "@/components/CustomModal"; +import { isChild } from "@/utils/fare-utils"; const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; @@ -36,28 +37,34 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => @@ -66,6 +73,31 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => BedCard.displayName = "BedCard"; +// A real berth ladder is a single fixed rail mounted at the end of the bay that a +// passenger climbs to reach every level — not a separate rung floating between each +// pair of beds. So this renders once per bay, right after the last berth card, with +// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. +const LadderConnector = memo(() => ( + +)); + +LadderConnector.displayName = "LadderConnector"; + const SeatButton = memo( ({ seat, @@ -134,6 +166,9 @@ export default function SeatsPage() { passengers, setSeatHold, setPassengers, + setSelectedSchedule, + setOutboundSchedule, + setInboundSchedule, searchCriteria, bookingId, packageName, @@ -146,6 +181,12 @@ export default function SeatsPage() { const [activePassengerIndex, setActivePassengerIndex] = useState(0); const [selectedCoach, setSelectedCoach] = useState(null); + // Label of the coach the user picked from the Train Coach Preview, waiting to be + // focused once the (possibly newly-fetched) seat map data for its coach type is ready. + // Matched by label rather than id since the preview and seatmap endpoints are separate + // API calls and may not share the same coach id scheme. + const [pendingCoachLabel, setPendingCoachLabel] = useState(null); + const [showCoachPreview, setShowCoachPreview] = useState(false); const [currentJourneyType, setCurrentJourneyType] = useState< "outbound" | "inbound" >("outbound"); @@ -154,6 +195,8 @@ export default function SeatsPage() { title: "", message: "", type: "info" as "warning" | "error" | "success" | "info", + onConfirm: undefined as (() => void) | undefined, + showCancel: false, }); const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; @@ -168,6 +211,58 @@ export default function SeatsPage() { ? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND" : "ONE_WAY"; + // Baseline fare for each leg as it was when this page first loaded — i.e. whatever was + // picked on the results page ("starting from" price). Captured once and never + // overwritten, so a later coach-type switch (or just picking a pricier berth) can still + // be compared against what the user originally saw/selected. + const originalFaresRef = useRef<{ outbound: number | null; inbound: number | null; oneWay: number | null }>({ + outbound: null, + inbound: null, + oneWay: null, + }); + if (originalFaresRef.current.outbound === null && outboundSchedule?.baseFareAdult != null) { + originalFaresRef.current.outbound = outboundSchedule.baseFareAdult; + } + if (originalFaresRef.current.inbound === null && inboundSchedule?.baseFareAdult != null) { + originalFaresRef.current.inbound = inboundSchedule.baseFareAdult; + } + if (originalFaresRef.current.oneWay === null && selectedSchedule?.baseFareAdult != null) { + originalFaresRef.current.oneWay = selectedSchedule.baseFareAdult; + } + const originalFareForCurrentLeg = isRoundTrip + ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound) + : originalFaresRef.current.oneWay; + + // Child seat allocation rule: let A = adults, C = children (isChild = under 5). + // If C > A, only A - 1 children get their own seat and the rest share with an adult. + // If C <= A, no child gets a separate seat — all of them share with an adult. + // Adults always need their own seat. + const seatEligibility = useMemo(() => { + const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i])); + const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i])); + const adultCount = adultIndices.length; + const childCount = childIndices.length; + const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0; + const eligibleChildIndices = childIndices.slice(0, eligibleChildCount); + const eligibleSet = new Set([...adultIndices, ...eligibleChildIndices]); + + // Children who don't get their own seat share with an adult (round-robin, for display). + const sharingWithAdult = new Map(); + childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => { + const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)]; + if (adultIdx != null) { + sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`); + } + }); + + return { eligibleSet, sharingWithAdult }; + }, [passengers]); + + const seatEligibleIndices = useMemo( + () => passengers.map((_, i) => i).filter((i) => seatEligibility.eligibleSet.has(i)), + [passengers, seatEligibility], + ); + const { data: seatMapData, isLoading, @@ -189,6 +284,161 @@ export default function SeatsPage() { enabled: !!currentSchedule?.id && !!coachTypeId, }); + // Whole-train coach layout for the "Preview Train Coach" panel — a separate, lazily + // fetched list of every coach on this schedule (not just the currently selected coach + // type), so users can see the full train arrangement and spot the dining coach. + const { data: trainCoachesData, isLoading: loadingTrainCoaches } = useQuery({ + queryKey: ["trainCoaches", currentSchedule?.id], + queryFn: async () => { + const response = await apiClient.get(`/seats/coaches/${currentSchedule?.id}`); + return (response as any)?.data || response; + }, + enabled: showCoachPreview && !!currentSchedule?.id, + }); + + const trainCoachList = useMemo(() => { + const raw = (trainCoachesData as any)?.coaches || trainCoachesData || []; + if (!Array.isArray(raw)) return []; + return raw + .map((c: any, idx: number) => ({ + id: c.id || c.coachId || String(idx), + label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`, + type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""), + typeName: c.coachTypeName || c.coachType || c.category || c.type || "", + coachTypeId: c.coachTypeId ?? c.typeId ?? null, + remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null, + sequence: c.sequence ?? c.order ?? idx, + })) + .sort((a: any, b: any) => a.sequence - b.sequence); + }, [trainCoachesData]); + + const isDiningCoachType = (type: string) => /dining|dpc/i.test(type); + + // Looks up a coach type's lowest per-adult fare from the coach-type/fare data captured + // when this schedule was first selected on the results page (see booking-store.ts). + // Returns null if the type can't be matched or has no priced classes. + const getCoachTypeFare = (coachTypeId: string | null | undefined): number | null => { + if (!coachTypeId) return null; + const types = (currentSchedule as any)?.coachTypes || []; + const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId); + const fares = (match?.classes || []).map((c: any) => c.baseFareMinor).filter((f: number) => f > 0); + return fares.length ? Math.min(...fares) : null; + }; + + // The current coach type's priced classes (e.g. bed coaches price Upper/Middle/Lower + // differently) — used to look up the real fare for a specific seat, not just the + // coach type's cheapest class. + const currentCoachTypeClasses = useMemo(() => { + const types = (currentSchedule as any)?.coachTypes || []; + const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId); + return match?.classes || []; + }, [currentSchedule, coachTypeId]); + + // A specific seat's actual fare: bed positions (Upper/Middle/Lower) are priced as + // separate classes, so this can differ from the coach type's flat minimum fare. + const getSeatFare = useCallback( + (seat: any): number | null => { + if (!currentCoachTypeClasses.length) return null; + if (seat?.bedPosition) { + const match = currentCoachTypeClasses.find((c: any) => + c.name?.toLowerCase().includes(seat.bedPosition), + ); + if (match) return match.baseFareMinor; + } + const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || "")); + return (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null; + }, + [currentCoachTypeClasses], + ); + + // Switches the schedule to a different coach type (updates fare/class fields in the + // booking store, which the seatmap query picks up automatically since it's keyed on + // coachTypeId) and clears any seat picks made under the old coach type, since they no + // longer correspond to real seats. Queues the clicked coach's label to be focused once + // the new seat map data has finished loading. + const applyCoachTypeSwitch = (coach: any, matchedType: any) => { + const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); + const firstClass = matchedType.classes?.[0]; + const updatedSchedule = { + ...(currentSchedule as any), + selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId, + selectedCoachTypeCode: matchedType.coachTypeCode, + selectedCoachTypeName: matchedType.coachTypeName, + selectedSeatClass: firstClass?.name || matchedType.coachTypeName, + selectedSeatClassName: firstClass?.name || matchedType.coachTypeName, + // review/page.tsx's fare-breakdown request reads THIS field (not + // selectedSeatClassName) to resolve the seat class — must stay in sync or the + // review page keeps pricing against the coach type the user switched away from. + seatClassName: firstClass?.name || matchedType.coachTypeName, + baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, + baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, + }; + + if (isRoundTrip && currentJourneyType === "inbound") { + setInboundSchedule(updatedSchedule); + } else if (isRoundTrip) { + setOutboundSchedule(updatedSchedule); + } else { + setSelectedSchedule(updatedSchedule); + } + + setPassengerSeatMap({}); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); + setSelectedCoach(null); + setPendingCoachLabel(coach.label); + setShowCoachPreview(false); + }; + + // Same coach type as the one already loaded — no refetch needed, just bring this + // specific physical coach into view once we can match it against the (already + // available) seat map data. + const focusCoachInPlace = (coach: any) => { + setPendingCoachLabel(coach.label); + setShowCoachPreview(false); + }; + + // Coach card click handler for the Train Coach Preview: validates availability, then + // immediately loads that coach's seat map (switching coach type if needed) — no price + // confirmation here. Individual seats within a coach type/bed coach can still be priced + // differently (e.g. Upper/Middle/Lower berths), so the fare confirmation instead happens + // at the point of actually picking a seat (see handleSeatClick), once real seat data is + // in view. + const handlePreviewCoachSelect = (coach: any) => { + if (coach.remainingSeats != null && coach.remainingSeats <= 0) { + setModalState({ + isOpen: true, + title: "Coach Full", + message: `${coach.label} has no remaining seats. Please choose a different coach.`, + type: "warning", + onConfirm: undefined, + showCancel: false, + }); + return; + } + + const types = (currentSchedule as any)?.coachTypes || []; + const matchedType = types.find( + (ct: any) => + ct.coachTypeId === coach.coachTypeId || + ct.coachId === coach.coachTypeId || + ct.coachTypeCode === coach.type || + ct.coachTypeName === coach.type, + ); + const isSameType = + matchedType && + (matchedType.coachTypeId === coachTypeId || matchedType.coachId === coachTypeId); + + if (!matchedType || isSameType) { + // Same coach type — just bring this physical coach's seat map into view. + focusCoachInPlace(coach); + return; + } + + // Different coach type — switch to it and load its seat map; per-seat fare + // confirmation (if any) happens once the user picks an actual seat. + applyCoachTypeSwitch(coach, matchedType); + }; + const holdMutation = useMutation({ mutationFn: async (seatIds: string[]) => { const passengersForHold = passengers @@ -267,6 +517,20 @@ export default function SeatsPage() { return coachesWithSeats; }, [coaches]); + // Resolves a coach picked from the Train Coach Preview once its seat map data is + // actually ready — matching by label (not id) since the preview list and this seat + // map come from separate API calls. Waits out any in-flight refetch triggered by a + // coach-type switch before trying to match, so it doesn't act on stale/old-type data. + useEffect(() => { + if (!pendingCoachLabel) return; + if (isLoading) return; + if (!filteredCoaches.length) return; + const match = filteredCoaches.find( + (c: any) => (c.label || c.name || c.coachNumber || "") === pendingCoachLabel, + ); + setSelectedCoach((match || filteredCoaches[0])?.id || null); + setPendingCoachLabel(null); + }, [pendingCoachLabel, filteredCoaches, isLoading]); const selectedCoachData = useMemo( () => filteredCoaches.find((c: any) => c.id === selectedCoach), @@ -346,13 +610,16 @@ export default function SeatsPage() { ); // The furthest passenger a user is allowed to jump to — cannot skip ahead of the - // first passenger who still needs a seat. + // first seat-eligible passenger who still needs a seat. Passengers who share a seat + // with an adult (see seatEligibility) never need their own pick. const firstUnassignedIndex = useMemo( - () => passengers.findIndex((_, i) => !passengerSeatMap[i]), - [passengers, passengerSeatMap], + () => seatEligibleIndices.find((i) => !passengerSeatMap[i]) ?? -1, + [seatEligibleIndices, passengerSeatMap], ); const maxSelectableIndex = - firstUnassignedIndex === -1 ? passengers.length - 1 : firstUnassignedIndex; + firstUnassignedIndex === -1 + ? seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0 + : firstUnassignedIndex; const isSeatSelected = useCallback( (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId, @@ -369,20 +636,18 @@ export default function SeatsPage() { const handleSelectPassenger = useCallback( (index: number) => { + if (!seatEligibility.eligibleSet.has(index)) return; // shares a seat with an adult — no pick needed if (index > maxSelectableIndex) return; // no skipping ahead of unassigned passengers setActivePassengerIndex(index); }, - [maxSelectableIndex], + [maxSelectableIndex, seatEligibility], ); - const handleSeatClick = useCallback( + // Actually applies a seat pick/deselect for the active passenger — split out from + // handleSeatClick so a price-difference confirmation can defer this until the user + // confirms, instead of assigning immediately. + const commitSeatAssignment = useCallback( (seatId: string) => { - // Seat already claimed by a different passenger — never allow duplicate assignment - const takenByOther = Object.entries(passengerSeatMap).some( - ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, - ); - if (takenByOther) return; - const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; const next = { ...passengerSeatMap }; if (isDeselecting) { @@ -394,26 +659,95 @@ export default function SeatsPage() { if (!isDeselecting) { // Move on to the next passenger who still needs a seat — one passenger at a time - const nextUnassigned = passengers.findIndex( - (_, i) => i !== activePassengerIndex && !next[i], + const nextUnassigned = seatEligibleIndices.find( + (i) => i !== activePassengerIndex && !next[i], ); - if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned); + if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned); } }, - [passengerSeatMap, activePassengerIndex, passengers], + [passengerSeatMap, activePassengerIndex, seatEligibleIndices], + ); + + const handleSeatClick = useCallback( + (seatId: string) => { + // Seat already claimed by a different passenger — never allow duplicate assignment + const takenByOther = Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); + if (takenByOther) return; + + const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; + if (isDeselecting) { + commitSeatAssignment(seatId); + return; + } + + // Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare + // differs from what the user originally selected (e.g. after switching coach type + // via the preview, or just picking a pricier berth) — or from another passenger's + // already-selected seat — needs a heads-up before it's applied. + const newSeat = validSeats?.find((s: any) => s.id === seatId); + const newFare = newSeat ? getSeatFare(newSeat) : null; + + if (newFare != null) { + let referenceFare: number | null = null; + let referenceLabel = "the fare you originally selected"; + + if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) { + referenceFare = originalFareForCurrentLeg; + } else { + const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { + if (Number(idx) === activePassengerIndex) return false; + const otherSeat = validSeats?.find((s: any) => s.id === sid); + const otherFare = otherSeat ? getSeatFare(otherSeat) : null; + return otherFare != null && otherFare !== newFare; + }); + + if (differingEntry) { + const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); + referenceFare = otherSeat ? getSeatFare(otherSeat) : null; + referenceLabel = "another already-selected seat"; + } + } + + if (referenceFare != null) { + const seatLabel = buildSeatLabel(newSeat); + const positionLabel = newSeat?.bedPosition + ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` + : "This seat"; + + setModalState({ + isOpen: true, + title: "Fare Will Change", + message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, + type: "warning", + showCancel: true, + onConfirm: () => commitSeatAssignment(seatId), + }); + return; + } + } + + commitSeatAssignment(seatId); + }, + [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment], ); const allSeatsAssigned = - passengers.length > 0 && - passengers.every((_, i) => !!passengerSeatMap[i]); + seatEligibleIndices.length > 0 && + seatEligibleIndices.every((i) => !!passengerSeatMap[i]); const handleContinue = async () => { if (!allSeatsAssigned) return; + // Indexed by original passenger position — holes for passengers who share a seat + // with an adult (no seat picked, and none required). const seatIds = passengers.map((_, i) => passengerSeatMap[i]); + // Only real, distinct seat ids go to the hold API. + const seatIdsForHold = seatEligibleIndices.map((i) => passengerSeatMap[i]); if (isRoundTrip && currentJourneyType === "outbound") { try { - await holdMutation.mutateAsync(seatIds); + await holdMutation.mutateAsync(seatIdsForHold); const updatedPassengers = passengers.map((p, i) => { const seatData = validSeats?.find((s: any) => s.id === seatIds[i]); return { @@ -421,6 +755,8 @@ export default function SeatsPage() { outboundSeatId: seatIds[i], outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '', + outboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined, + outboundBedPosition: seatData?.bedPosition || undefined, }; }); setPassengers(updatedPassengers); @@ -432,18 +768,20 @@ export default function SeatsPage() { error?.response?.data?.message || "Failed to hold seats. Please try again.", type: "error", + onConfirm: undefined, + showCancel: false, }); return; } setCurrentJourneyType("inbound"); setPassengerSeatMap({}); - setActivePassengerIndex(0); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); setSelectedCoach(null); return; } try { - await holdMutation.mutateAsync(seatIds); + await holdMutation.mutateAsync(seatIdsForHold); const updatedPassengers = passengers.map((p, i) => { const seatData = validSeats?.find((s: any) => s.id === seatIds[i]); if (isRoundTrip && currentJourneyType === "inbound") { @@ -452,6 +790,8 @@ export default function SeatsPage() { inboundSeatId: seatIds[i], inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '', + inboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined, + inboundBedPosition: seatData?.bedPosition || undefined, }; } return { @@ -459,6 +799,8 @@ export default function SeatsPage() { seatId: seatIds[i], seatNumber: seatData ? buildSeatLabel(seatData) : '', coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '', + seatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined, + bedPosition: seatData?.bedPosition || undefined, }; }); setPassengers(updatedPassengers); @@ -470,6 +812,8 @@ export default function SeatsPage() { error?.response?.data?.message || "Failed to hold seats. Please try again.", type: "error", + onConfirm: undefined, + showCancel: false, }); return; } @@ -477,9 +821,7 @@ export default function SeatsPage() { }; const handleAutoAssign = () => { - const unassignedIndices = passengers - .map((_, i) => i) - .filter((i) => !passengerSeatMap[i]); + const unassignedIndices = seatEligibleIndices.filter((i) => !passengerSeatMap[i]); if (unassignedIndices.length === 0) return; @@ -493,6 +835,8 @@ export default function SeatsPage() { title: "Not Enough Seats", message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`, type: "warning", + onConfirm: undefined, + showCancel: false, }); return; } @@ -502,7 +846,7 @@ export default function SeatsPage() { next[passengerIndex] = availableSeats[offset].id; }); setPassengerSeatMap(next); - setActivePassengerIndex(passengers.length - 1); + setActivePassengerIndex(seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0); }; const handleBackToPassengers = () => { @@ -536,6 +880,15 @@ export default function SeatsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); + // A passenger who shares a seat with an adult should never be "active" for seat + // picking — snap back to the first seat-eligible passenger if that ever happens + // (e.g. right after passengers/counts change). + useEffect(() => { + if (!seatEligibility.eligibleSet.has(activePassengerIndex) && seatEligibleIndices.length > 0) { + setActivePassengerIndex(seatEligibleIndices[0]); + } + }, [seatEligibility, seatEligibleIndices, activePassengerIndex]); + const parseSeatArrangement = ( arrangement: string | null, seatClasses?: string[], @@ -565,13 +918,6 @@ export default function SeatsPage() { return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; }; - const getBedLabel = (bedPosition: string | null): string => { - if (bedPosition === "upper") return "U"; - if (bedPosition === "middle") return "M"; - if (bedPosition === "lower") return "L"; - return ""; - }; - const renderCoachSeats = (coach: any, isBedCoach: boolean) => { const arrangement = parseSeatArrangement( coach.seatArrangement, @@ -588,6 +934,54 @@ export default function SeatsPage() { ? selectedCoachData.seatClass : selectedCoachData?.seatClass?.name || ""; + // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with + // the single ladder that actually serves the whole bay shown once at the end. + const renderBerthBay = (beds: any[], keyPrefix: string) => ( +
+ {beds.map((bed: any) => ( + + ))} + {beds.length > 1 && } +
+ ); + + // Two-side compartment: the left bay and right bay each get their own row (berths + // still laid out horizontally within a row), stacked one above the other and split + // by a dashed aisle divider — instead of squeezing both sides into a single row. + const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( +
+
+ {leftBay.length > 0 && ( +
{renderBerthBay(leftBay, `${key}-left`)}
+ )} + {leftBay.length > 0 && rightBay.length > 0 && ( +
+ )} + {rightBay.length > 0 && ( +
{renderBerthBay(rightBay, `${key}-right`)}
+ )} +
+
+ ); + + // Bay position ordering + left/right side detection shared by both bed layouts below. + const BERTH_ORDER = ["lower", "middle", "upper"]; + const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { + if (bed.position === "LEFT") return true; + if (bed.position === "RIGHT") return false; + const leftCol = leftColByPosition[bed.bedPosition]; + return leftCol ? bed.col === leftCol : true; + }; + // Bed coach with bed positions (Upper, Middle, Lower) if (isBedCoach && hasBedPositionData) { // Check if this is VIP_BED or ECONOMY_BED based on room data @@ -655,205 +1049,35 @@ export default function SeatsPage() {
- {/* VIP BED Layout (2x2 grid) */} - {isVipBed && ( -
- {/* Upper Berths */} -
-
- Upper -
- Berth -
-
- {sortedBeds - .filter((b: any) => b.bedPosition === "upper") - .map((bed: any) => ( -
- -
- ))} - {/* Vertical aisle indicator */} -
-
-
+ {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} + {isVipBed && (() => { + const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); + const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); + const isLeft = (bed: any, idx: number) => + bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; - {/* Lower Berths */} -
-
- Lower -
- Berth -
-
- {sortedBeds - .filter((b: any) => b.bedPosition === "lower") - .map((bed: any) => ( -
- -
- ))} - {/* Vertical aisle indicator */} -
-
-
-
- )} + const leftBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) + .filter(Boolean); + const rightBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) + .filter(Boolean); - {/* ECONOMY BED Layout (3 rows: Lower, Middle, Upper on both sides) */} - {isEconomyBed && ( -
- {/* Lower Berths */} -
-
- Lower -
- Berth -
-
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "lower" && - (b.col === "A" || b.position === "LEFT"), - ) - .map((bed: any) => ( - - ))} - {/* Vertical aisle */} -
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "lower" && - b.col !== "A" && - b.position !== "LEFT", - ) - .map((bed: any) => ( - - ))} -
-
+ return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); + })()} - {/* Middle Berths */} -
-
- Middle -
- Berth -
-
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "middle" && - (b.col === "B" || b.position === "LEFT"), - ) - .map((bed: any) => ( - - ))} - {/* Vertical aisle */} -
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "middle" && - b.col !== "B" && - b.position !== "LEFT", - ) - .map((bed: any) => ( - - ))} -
-
+ {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} + {isEconomyBed && (() => { + const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; + const leftBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + const rightBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); - {/* Upper Berths */} -
-
- Upper -
- Berth -
-
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "upper" && - (b.col === "C" || b.position === "LEFT"), - ) - .map((bed: any) => ( - - ))} - {/* Vertical aisle */} -
- {sortedBeds - .filter( - (b: any) => - b.bedPosition === "upper" && - b.col !== "C" && - b.position !== "LEFT", - ) - .map((bed: any) => ( - - ))} -
-
-
- )} + return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); + })()}
); })} @@ -861,7 +1085,8 @@ export default function SeatsPage() { ); } - // Fallback: Old layout for beds without room data + // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), + // then pair adjacent bays into two-side compartments, same as the room-based layouts. const seatGroups = new Map(); for (const seat of validSeats) { @@ -878,88 +1103,18 @@ export default function SeatsPage() { return numA - numB; }); + const bays = sortedGroups + .map(([, beds]) => + BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), + ) + .filter((bay) => bay.length > 0); + return (
- {sortedGroups.map(([seatNumber, beds], idx) => { - const shouldFlipIcon = idx % 2 === 0; - - const orderedBeds = ["lower", "middle", "upper"] - .map((pos) => beds.find((seat) => seat.bedPosition === pos)) - .filter((seat) => seat !== undefined); - - if (orderedBeds.length === 0) return null; - - return ( -
-
- {shouldFlipIcon && ( -
- {orderedBeds.map((seat: any) => { - const seatLabel = - seat.seatNumber || seat.number || seat.label || ""; - const bedLabelFull = seat.bedPosition - ? seat.bedPosition === "upper" - ? "Upper" - : seat.bedPosition === "middle" - ? "Middle" - : "Lower" - : ""; - return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""} -
- ); - })} -
- )} - -
- {orderedBeds.map((seat: any) => ( - - ))} -
- - {!shouldFlipIcon && ( -
- {orderedBeds.map((seat: any) => { - const seatLabel = - seat.seatNumber || seat.number || seat.label || ""; - const bedLabelFull = seat.bedPosition - ? seat.bedPosition === "upper" - ? "Upper" - : seat.bedPosition === "middle" - ? "Middle" - : "Lower" - : ""; - return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""} -
- ); - })} -
- )} -
-
- ); + {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { + const leftBay = bays[i * 2] || []; + const rightBay = bays[i * 2 + 1] || []; + return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); })}
); @@ -1111,7 +1266,7 @@ export default function SeatsPage() { ); } - const assignedCount = passengers.filter((_, i) => !!passengerSeatMap[i]).length; + const assignedCount = seatEligibleIndices.filter((i) => !!passengerSeatMap[i]).length; const activePassenger = passengers[activePassengerIndex]; const isBedCoach = selectedCoachData?.isBedCoach === true || @@ -1119,6 +1274,114 @@ export default function SeatsPage() { selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); + // Train coach preview content — shared between the desktop side panel and the + // mobile full-screen modal. Shows every coach on this schedule in order, so users + // can see where their selected coach type sits relative to the rest of the train + // and spot the dining coach at a glance. + const TrainCoachPreviewContent = () => ( + <> +

+ Coach order along the train, front to back. +

+ + {/* Legend */} +
+
+
+ 🍽 Dining coach +
+
+
+ Your selected coach type +
+
+ + {loadingTrainCoaches ? ( +
+
+ Loading coach layout... +
+ ) : trainCoachList.length === 0 ? ( +

+ Coach layout is not available for this train. +

+ ) : ( +
+ {/* Locomotive */} +
+ + Locomotive +
+ + {trainCoachList.map((coach: any, coachIndex: number) => { + const dining = isDiningCoachType(coach.type); + const isFull = !dining && coach.remainingSeats != null && coach.remainingSeats <= 0; + const isCurrentType = + !!coachTypeId && + (coach.coachTypeId === coachTypeId || + coach.type === (currentSchedule as any)?.selectedCoachTypeCode || + coach.type === (currentSchedule as any)?.selectedCoachTypeName); + return ( +
+ {/* Coach connector — same coupling-joint visual as the seat map's coach list */} +
+
+
+
+
+
+
+ + +
+ ); + })} +
+ )} + + ); + // Summary card content — shared between sidebar and mobile modal const SummaryContent = () => ( <> @@ -1133,13 +1396,13 @@ export default function SeatsPage() { : "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" }`} > - {assignedCount}/{passengers.length} selected + {assignedCount}/{seatEligibleIndices.length} selected

{allSeatsAssigned ? "All seats selected — ready to continue" - : `Selecting seat for ${activePassenger?.name || `Passenger ${activePassengerIndex + 1}`} (${activePassengerIndex + 1} of ${passengers.length})`} + : `Selecting seat for ${activePassenger?.name || `Passenger ${activePassengerIndex + 1}`} (${seatEligibleIndices.indexOf(activePassengerIndex) + 1} of ${seatEligibleIndices.length})`}

{/* Progress bar */} @@ -1147,13 +1410,35 @@ export default function SeatsPage() {
{passengers.map((p, i) => { + const sharesWithAdult = seatEligibility.sharingWithAdult.get(i); + if (sharesWithAdult) { + return ( +
+
+
+ {i + 1} +
+ + {p.name} + +
+ + Shares seat with {sharesWithAdult} + +
+ ); + } + const assignedSeatId = passengerSeatMap[i]; const assignedSeat = assignedSeatId ? validSeats?.find((s: any) => s.id === assignedSeatId) @@ -1241,8 +1526,62 @@ export default function SeatsPage() { title={modalState.title} message={modalState.message} type={modalState.type} + onConfirm={modalState.onConfirm} + showCancel={modalState.showCancel} + confirmText={modalState.showCancel ? "Switch Coach" : "OK"} /> + {/* Train Coach Preview */} + {showCoachPreview && ( + <> + {/* Mobile: full-screen modal */} +
+
+

+ + Train Coach Preview +

+ +
+
+ +
+
+ + {/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */} +
+
+

+ + Train Coach Preview +

+ +
+
+ +
+
+ + + )} + {/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
{/* ── Seat map panel ── */}
+
+ +
{isLoading ? (
diff --git a/apps/edr-passenger-web/portal/src/lib/api-client.ts b/apps/edr-passenger-web/portal/src/lib/api-client.ts index 472c66e70..471218532 100644 --- a/apps/edr-passenger-web/portal/src/lib/api-client.ts +++ b/apps/edr-passenger-web/portal/src/lib/api-client.ts @@ -21,7 +21,11 @@ class ApiClient { return config; }); - const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me']; + // /fayda/verification/* is used by guests filling out the passenger form, and a 401 + // there just means the user canceled/closed the Fayda popup without completing it (no + // valid verification session) — that should surface as an inline error on the page, + // not force-clear the session and redirect to /login out from under them. + const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification']; this.client.interceptors.response.use( (response) => response, diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index e2a518b57..ec2b34e5b 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -30,6 +30,15 @@ export interface PassengerDetail { seatId?: string; seatNumber?: string; coachNumber?: string; + // Fare for this passenger's actual assigned seat (minor units). Bed coaches price + // Upper/Middle/Lower differently, so this can differ from the schedule's flat + // baseFareAdult (which is only the coach type's cheapest class) — review/payment + // should prefer this per-seat fare when it's available. + seatFareMinor?: number; + // Raw bed position ("lower"/"middle"/"upper") of the assigned seat, if it's a berth — + // used to resolve this passenger's actual seat class server-side (see review page's + // fare-breakdown request), since a shared coach-level class can't distinguish berths. + bedPosition?: string; phone?: string; email?: string; gender?: string; @@ -37,9 +46,13 @@ export interface PassengerDetail { outboundSeatId?: string; outboundSeatNumber?: string; outboundCoachNumber?: string; + outboundSeatFareMinor?: number; + outboundBedPosition?: string; inboundSeatId?: string; inboundSeatNumber?: string; inboundCoachNumber?: string; + inboundSeatFareMinor?: number; + inboundBedPosition?: string; returnSeatId?: string; returnSeatNumber?: string; } @@ -63,6 +76,21 @@ export interface SelectedSchedule { selectedCoachTypeId?: string; selectedCoachTypeCode?: string; selectedCoachTypeName?: string; + // All coach types (with per-class fares) offered on this schedule at selection time — + // kept so the seat map's coach preview can compute the fare difference before letting + // a passenger switch to a different coach type. + coachTypes?: Array<{ + coachId: string; + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + classes: Array<{ + name: string; + baseFareMinor: number; + displayCurrency?: string; + displayAmountMinor?: number; + }>; + }>; } export interface SeatHold { 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 4dd969d69..37f421d71 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,5 +1,6 @@ import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; +import QRCode from 'qrcode'; interface ScheduleInfo { trainNumber: string; @@ -36,6 +37,40 @@ const DARK = [51, 51, 51] as const; const MED = [102, 102, 102] as const; const LIGHT = [200, 200, 200] as const; +// ─── QR code ─────────────────────────────────────────────────────────────── +// Encodes everything a gate scanner needs to verify this specific ticket without +// a network round-trip: booking reference, ticket number, passenger, train, seat(s), +// departure time and fare. Kept as compact JSON so any generic QR reader can parse it. +function buildTicketQrPayload(data: PassengerVoucherData): string { + return JSON.stringify({ + type: 'EDR_TICKET', + pnr: data.bookingRef, + ticket: data.ticketNumber, + passenger: data.passengerName, + status: data.status, + train: data.outboundSchedule.trainNumber, + seat: data.isRoundTrip + ? { outbound: data.outboundSeatNumber || null, inbound: data.inboundSeatNumber || null } + : (data.seatNumber || null), + departure: data.outboundSchedule.departureAt, + fare: { amountMinor: data.fareMinor, currency: data.currency }, + }); +} + +async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise { + try { + return await QRCode.toDataURL(buildTicketQrPayload(data), { + width: 240, + margin: 0, + errorCorrectionLevel: 'M', + color: { dark: '#0f172a', light: '#ffffff' }, + }); + } catch (error) { + console.error('Failed to generate ticket QR code:', error); + return null; + } +} + async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); @@ -81,22 +116,41 @@ function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: numbe return y + 12; } -function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number { +function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number { + const boxHeight = 32; + const qrSize = 24; + const qrPad = 3; + const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0; + doc.setFillColor(245, 245, 245); - doc.rect(margin, y, pageWidth - margin * 2, 22, 'F'); + doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F'); + // Booking reference (top-left) doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); - doc.text('BOOKING REFERENCE', margin + 5, y + 6); - doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); - doc.text(bookingRef, margin + 5, y + 14); + doc.text('BOOKING REFERENCE', margin + 5, y + 8); + doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text(bookingRef, margin + 5, y + 18); - const rightX = pageWidth - margin - 5; + // Ticket number, stacked below — leaves room on the right for the QR block + const textRightBound = pageWidth - margin - qrBlockWidth - 5; doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); - doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' }); + doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' }); doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); - doc.text(ticketNumber, rightX, y + 14, { align: 'right' }); + doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' }); - return y + 28; + // QR code — clean white card with a thin border, right-aligned in the box + if (qrDataUrl) { + const cardSize = qrSize + qrPad * 2; + const cardX = pageWidth - margin - cardSize - 3; + const cardY = y + (boxHeight - cardSize) / 2; + doc.setFillColor(255, 255, 255); + doc.setDrawColor(...LIGHT); + doc.setLineWidth(0.4); + doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD'); + doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize); + } + + return y + boxHeight + 6; } function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number { @@ -244,6 +298,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P const margin = 15; let y = await drawHeader(doc, margin); + const qrDataUrl = await generateTicketQrDataUrl(data); // Title doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); @@ -251,7 +306,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P y += 10; y = drawStatusBadge(doc, data.status, y, pageW); - y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW); + y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW); y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); if (data.isRoundTrip && data.inboundSchedule) { diff --git a/apps/edr-passenger-web/portal/src/lib/payment-store.ts b/apps/edr-passenger-web/portal/src/lib/payment-store.ts index 057639004..85b74b55c 100644 --- a/apps/edr-passenger-web/portal/src/lib/payment-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/payment-store.ts @@ -3,11 +3,17 @@ import { create } from 'zustand'; interface PaymentState { paymentIntentId: string | null; paymentStatus: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED' | null; + // The currency actually confirmed for the selected payment option (from + // /payments/booking-amount), not just a default — kept in sync by the payment page. selectedCurrency: 'ETB' | 'DJF' | 'USD'; - + // The exact minor-unit amount confirmed for that currency/payment option. Downstream + // screens (e.g. the voucher) should use this instead of recomputing a default ETB fare. + paidAmountMinor: number | null; + setPaymentIntent: (id: string) => void; updateStatus: (status: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED') => void; setCurrency: (currency: 'ETB' | 'DJF' | 'USD') => void; + setPaidAmount: (amountMinor: number) => void; clearPayment: () => void; } @@ -15,13 +21,16 @@ export const usePaymentStore = create((set) => ({ paymentIntentId: null, paymentStatus: null, selectedCurrency: 'ETB', + paidAmountMinor: null, setPaymentIntent: (id) => set({ paymentIntentId: id }), updateStatus: (status) => set({ paymentStatus: status }), setCurrency: (currency) => set({ selectedCurrency: currency }), + setPaidAmount: (amountMinor) => set({ paidAmountMinor: amountMinor }), clearPayment: () => set({ paymentIntentId: null, paymentStatus: null, selectedCurrency: 'ETB', + paidAmountMinor: null, }), }));