diff --git a/apps/edr-passenger-web/portal/public/packages/package.jpeg b/apps/edr-passenger-web/portal/public/packages/package.jpeg new file mode 100644 index 000000000..6b42468ae Binary files /dev/null and b/apps/edr-passenger-web/portal/public/packages/package.jpeg differ diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 9180bfa2f..3c8e66b98 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -95,7 +95,7 @@ interface PackageDetail { busTransferRoute: string | null; validFrom: string; validUntil: string; - journeyType: 'ONE_WAY' | 'ROUND_TRIP'; + journeyType: "ONE_WAY" | "ROUND_TRIP"; priceTiers: PriceTier[]; outboundSchedule: Schedule; returnSchedule: Schedule | null; @@ -109,17 +109,28 @@ interface PackageDetail { function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string { try { return new Date(iso).toLocaleDateString("en-US", { - weekday: "short", month: "short", day: "numeric", + weekday: "short", + month: "short", + day: "numeric", ...opts, timeZone: "Africa/Addis_Ababa", }); - } catch { return iso; } + } catch { + return iso; + } } function fmtTime(iso: string): string { try { - return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZone: "Africa/Addis_Ababa" }); - } catch { return iso; } + return new Date(iso).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: "Africa/Addis_Ababa", + }); + } catch { + return iso; + } } function durationLabel(minutes: number): string { @@ -138,7 +149,13 @@ function formatPrice(minor: number, currency: string): string { // ─── Journey Card ───────────────────────────────────────────────────────────── -function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) { +function JourneyCard({ + schedule, + label, +}: { + schedule: Schedule; + label: string; +}) { const dep = new Date(schedule.departureAt); const arr = new Date(schedule.arrivalAt); const isSameDay = dep.toDateString() === arr.toDateString(); @@ -147,8 +164,12 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
- {label} - {schedule.train.number} + + {label} + + + {schedule.train.number} +
@@ -156,11 +177,15 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
{/* Origin */}
-

From

+

+ From +

{schedule.originStation.code}

-

{schedule.originStation.name.trim()}

+

+ {schedule.originStation.name.trim()} +

{/* Center: duration + arrow */} @@ -172,31 +197,53 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
- {schedule.stopsCount} stops + + {schedule.stopsCount} stops +
{/* Destination */}
-

To

+

+ To +

{schedule.destinationStation.code}

-

{schedule.destinationStation.name.trim()}

+

+ {schedule.destinationStation.name.trim()} +

{/* Times */}
-

{fmtTime(schedule.departureAt)}

-

{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}

+

+ {fmtTime(schedule.departureAt)} +

+

+ {fmt(schedule.departureAt, { + weekday: "short", + month: "short", + day: "numeric", + })} +

{fmtTime(schedule.arrivalAt)} - {!isSameDay && +1} + {!isSameDay && ( + +1 + )} +

+

+ {fmt(schedule.arrivalAt, { + weekday: "short", + month: "short", + day: "numeric", + })}

-

{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}

@@ -215,7 +262,16 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ minPrice: number; currency: string; }> { - const map = new Map(); + const map = new Map< + string, + { + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + coachTypeType: string; + tiers: PriceTier[]; + } + >(); for (const tier of tiers) { const ct = tier.seatClass?.coachType; const key = ct?.id ?? `__ungrouped__${tier.seatType}`; @@ -223,8 +279,8 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ map.set(key, { coachTypeId: ct?.id ?? key, coachTypeName: ct?.name ?? tier.label ?? tier.seatType, - coachTypeCode: ct?.code ?? '', - coachTypeType: ct?.type ?? 'passenger', + coachTypeCode: ct?.code ?? "", + coachTypeType: ct?.type ?? "passenger", tiers: [], }); } @@ -233,20 +289,20 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ return Array.from(map.values()).map((g) => ({ ...g, minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)), - currency: g.tiers[0]?.currency ?? 'ETB', + currency: g.tiers[0]?.currency ?? "ETB", })); } function getCoachIcon(coachTypeType: string) { const lower = coachTypeType.toLowerCase(); - if (lower.includes('sleeper')) return Star; - if (lower.includes('bed') || lower.includes('sleep')) return Bed; + if (lower.includes("sleeper")) return Star; + if (lower.includes("bed") || lower.includes("sleep")) return Bed; return Armchair; } // Capitalise first letter of each word, replace underscores with spaces function formatCoachTypeLabel(type: string): string { - return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } function PriceTiersPanel({ @@ -265,14 +321,18 @@ function PriceTiersPanel({ if (!tiers?.length) { return (
-

No price tiers available

+

+ No price tiers available +

); } return (
-

Choose Coach Type

+

+ Choose Coach Type +

{groups.map((group, index) => { const CoachIcon = getCoachIcon(group.coachTypeType); @@ -283,37 +343,58 @@ function PriceTiersPanel({ key={group.coachTypeId} role="button" tabIndex={allSoldOut ? -1 : 0} - onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + onClick={() => + !allSoldOut && + setSelectedId(isSelected ? null : group.coachTypeId) + } onKeyDown={(e) => { - if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) { + if (!allSoldOut && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); setSelectedId(isSelected ? null : group.coachTypeId); } }} - className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut - ? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed' + className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${ + allSoldOut + ? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed" : isSelected - ? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer' - : 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer' - }`} - style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }} + ? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer" + : "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer" + }`} + style={{ + animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both`, + }} > {/* Radio indicator */} {!allSoldOut && ( - - {isSelected && } + + {isSelected && ( + + )} )}
-
- +
+
@@ -321,15 +402,26 @@ function PriceTiersPanel({ {formatCoachTypeLabel(group.coachTypeType)}

{allSoldOut && ( - Sold out + + Sold out + )}
- From - + + From + + {((group.minPrice * priceMultiplier) / 100).toFixed(2)} - {group.currency} + + {group.currency} +
@@ -343,17 +435,26 @@ function PriceTiersPanel({ return (
- {tier.seatType.trim()} + + {tier.seatType.trim()} + {soldOut ? ( - Sold out + + Sold out + ) : (
- {((tier.priceMinor * priceMultiplier) / 100).toFixed(2)} + {( + (tier.priceMinor * priceMultiplier) / + 100 + ).toFixed(2)} + + + {tier.currency} - {tier.currency}
)}
@@ -372,7 +473,10 @@ function PriceTiersPanel({ {isSelected && !allSoldOut && (
-

Coach type

-

{tier.seatClass?.coachType?.name ?? tier.label.trim()}

-

{remaining} seats remaining · from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

+

+ Coach type +

+

+ {tier.seatClass?.coachType?.name ?? tier.label.trim()} +

+

+ {remaining} seats remaining · from{" "} + {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}{" "} + per adult · 1st child per adult free (no seat) +

{[ - { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } }, - { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount }, + { + label: "Adults", + sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, + value: adultCount, + min: 1, + max: Math.min(PKG_MAX_ADULTS, remaining), + set: (v: number) => { + setAdultCount(v); + const newMax = Math.min( + v * PKG_CHILDREN_PER_ADULT, + v + Math.max(0, remaining - v), + ); + setChildCount((c) => Math.min(c, newMax)); + }, + }, + { + label: "Children", + sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, + value: childCount, + min: 0, + max: Math.min( + adultCount * PKG_CHILDREN_PER_ADULT, + adultCount + Math.max(0, remaining - adultCount), + ), + set: setChildCount, + }, ].map(({ label, sub, value, min, max, set }) => (
-

{label}

+

+ {label} +

{sub}

- - {value} -
@@ -467,22 +647,64 @@ function PassengerCountModal({ {freeChildren > 0 && (
- {freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat) + + {freeChildren} child{freeChildren > 1 ? "ren" : ""} travel + free (no seat) + ETB 0.00
)}
- Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''} - {formatPrice(totalMinor, tier.currency)} + + Total{priceMultiplier === 2 ? " (round-trip) from:" : ""} + + + {formatPrice(totalMinor, tier.currency)} +
{error && (
-

{error}

+

+ {error} +

)} + {/* Nationality */} +
+

+ Nationality +

+
+ {natOptions.map((opt) => ( + + ))} +
+ {showNationalityError && !nationality && ( +

+ Please select a nationality to continue. +

+ )} +
+ {/* Departure Station */}
-

For informational purposes — pricing remains fixed regardless of boarding point.

+

+ For informational purposes — pricing remains fixed regardless of + boarding point. +

{showStationError && !departureStationId && ( -

Please select your boarding station to continue.

+

+ Please select your boarding station to continue. +

)} -
@@ -528,14 +789,30 @@ export default function PackageDetailPage() { const params = useParams(); const router = useRouter(); const id = params?.id as string; - const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore(); + const { + clearBooking, + setSearchCriteria, + setSelectedSchedule, + setOutboundSchedule, + setInboundSchedule, + setPassengers, + setPackageContext, + } = useBookingStore(); - const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(null); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState( + null, + ); const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [bookingContextLoading, setBookingContextLoading] = useState(false); - const [bookingContextError, setBookingContextError] = useState(null); + const [bookingContextError, setBookingContextError] = useState( + null, + ); - const { data: pkg, isLoading, isError } = useQuery({ + const { + data: pkg, + isLoading, + isError, + } = useQuery({ queryKey: ["package", id], queryFn: async () => (await apiClient.get(`/packages/${id}`)) as PackageDetail, @@ -549,15 +826,26 @@ export default function PackageDetailPage() { // For the passenger modal, use the cheapest available tier in the selected coach type group const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : []; - const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId); + const selectedGroup = groups.find( + (g) => g.coachTypeId === selectedCoachTypeId, + ); // Representative tier for the modal: cheapest available in the selected group - const representativeTier = selectedGroup?.tiers - .filter((t) => t.availableSeats > 0) - .sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null; + const representativeTier = + selectedGroup?.tiers + .filter((t) => t.availableSeats > 0) + .sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? + selectedGroup?.tiers[0] ?? + null; - const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP'; + const isRoundTripPkg = pkg?.journeyType === "ROUND_TRIP"; - const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => { + const handleBookNow = async ( + adultCount: number, + childCount: number, + departureStationId: string, + departureStationName: string, + nationality: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER", + ) => { if (!representativeTier || !pkg) return; setBookingContextLoading(true); setBookingContextError(null); @@ -577,10 +865,13 @@ export default function PackageDetailPage() { origin: s.originStation?.name ?? "", destination: s.destinationStation?.name ?? "", originStationId: s.originStationId ?? s.originStation?.id ?? "", - destinationStationId: s.destinationStationId ?? s.destinationStation?.id ?? "", + destinationStationId: + s.destinationStationId ?? s.destinationStation?.id ?? "", departureTime: s.departureAt, arrivalTime: s.arrivalAt, - duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "", + duration: s.durationMinutes + ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` + : "", baseFareAdult: Math.round(ctx.totalMinor / passengerCount), baseFareChild: 0, displayCurrency: representativeTier.currency, @@ -590,19 +881,27 @@ export default function PackageDetailPage() { selectedCoachTypeId: ctx.coachTypeId ?? "", selectedCoachTypeCode: ctx.coachTypeCode ?? "", selectedCoachTypeName: ctx.coachTypeName ?? "", - coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({ - coachId: g.coachTypeId, - coachTypeId: g.coachTypeId, - coachTypeName: g.coachTypeName, - coachTypeCode: g.coachTypeCode, - classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })), - })), + coachTypes: Array.isArray(ctx.coachTypes) + ? ctx.coachTypes + : groups.map((g) => ({ + coachId: g.coachTypeId, + coachTypeId: g.coachTypeId, + coachTypeName: g.coachTypeName, + coachTypeCode: g.coachTypeCode, + classes: g.tiers.map((t) => ({ + name: t.label, + baseFareMinor: t.priceMinor, + })), + })), }); // booking-context returns flat originStationId/destinationStationId, not nested objects. // Use the user's selected departure station as the true origin for both the // search criteria and the schedule objects so hold/booking APIs get the right segment. - const outboundDestId = ctx.outboundSchedule.destinationStationId ?? ctx.outboundSchedule.destinationStation?.id ?? ""; + const outboundDestId = + ctx.outboundSchedule.destinationStationId ?? + ctx.outboundSchedule.destinationStation?.id ?? + ""; const outboundSched = { ...toSchedule(ctx.outboundSchedule), @@ -618,7 +917,16 @@ export default function PackageDetailPage() { tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY", adultCount, childCount, - nationality: "ETHIOPIAN", + // Must be the real picked value, not hardcoded — /booking/passengers' form + // only reads a passenger's own stored nationality when resuming a session + // with a name already filled in; for a brand-new booking (every package + // passenger starts with an empty name) it falls back to searchCriteria's + // nationality only, so a hardcoded value here would silently override + // whatever was actually picked and always show the Fayda gate. Safe for + // amount/currency: package schedules always carry their own fixed + // displayCurrency (set below in toSchedule()), which takes priority over + // any nationality-based fallback, so this has no effect on price/currency. + nationality, }); if (isRoundTrip) { @@ -643,8 +951,16 @@ export default function PackageDetailPage() { name: "", // Free children travel without a seat — give them a synthetic DOB that // makes isChild() return true so seat eligibility logic excludes them. - dateOfBirth: isFreeChild ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : "", - nationality: "ETHIOPIAN", + dateOfBirth: isFreeChild + ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10) + : "", + // Drives Fayda verification show/hide on /booking/passengers (existing + // logic there) — searchCriteria.nationality above is deliberately left + // as "ETHIOPIAN" since package pricing/currency is fixed per tier and + // must not change based on this. + nationality, isPrimaryPassenger: i === 0, }; }), @@ -653,12 +969,20 @@ export default function PackageDetailPage() { // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing // Store the group's cheapest tier price as a placeholder — the actual berth fare // (Upper/Middle/Lower) will be resolved and saved when the user picks a seat. - setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName); + setPackageContext( + id, + representativeTier.id, + representativeTier.priceMinor, + pkg.name, + departureStationId, + departureStationName, + ); router.push("/booking/passengers"); } catch (err: any) { setBookingContextError( - err?.response?.data?.message ?? "Failed to load booking context. Please try again.", + err?.response?.data?.message ?? + "Failed to load booking context. Please try again.", ); } finally { setBookingContextLoading(false); @@ -681,7 +1005,9 @@ export default function PackageDetailPage() {
-

Unable to load package details.

+

+ Unable to load package details. +