diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index d8404e84c..cddc67b2d 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + // axios defaults to no timeout — a hanging gateway would block the caller + // (and any transaction it sits in) indefinitely. Always bound the wait. + const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); + try { const response = await axios.post( url, @@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy { callbackUrl: "", }, { + timeout, headers: { accept: "*/*", "Content-Type": "application/json", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 9b2f53a99..06e84d455 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -860,6 +860,25 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + /** Sent after the transaction commits so the gateway never blocks the receive. */ + const pendingNotifications: Array<{ + owner: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }; + booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }; + bookingId: string; + }> = []; await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -1032,21 +1051,33 @@ export class WarehouseInventoryService { manager, ); - await this.notifyOwnerInventoryReceived({ - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }, + booking, + bookingId, }); result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); - void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); + // Fan out after commit, un-awaited: the receive response must not wait on the + // SMS gateway. Both notifiers swallow their own errors. + for (const pending of pendingNotifications) { + void this.notifyOwnerInventoryReceived(pending.owner); + void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + } + return result; } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 733c882bb..afa15d8d1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,40 +1,100 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; -import { ChevronRight, PackageCheck, Ship } from "lucide-react"; +import { + Badge, + Box, + Card, + Group, + Loader, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { ChevronRight, FileSignature, PackageCheck, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; +type QueueTab = "contracts" | "shipments"; + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +/** + * GL Djibouti clearance queues: + * - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow). + * - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ + * action (DO collection after ET finalizes pre-clearance, RO for exports, + * loading milestones). Managed like the one-time flow, but per booking. + */ export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); + const [tab, setTab] = useState("shipments"); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue(); const contractItems = contractQueue?.items ?? []; const bookingItems = bookingQueue ?? []; + const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading; return ( - - {contractsLoading || bookingsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 && bookingItems.length === 0 ? ( - - No Djibouti customs work yet. - - ) : ( - <> - {contractItems.map((c) => ( + + + + setTab(v as QueueTab)} + radius="md" + data={[ + { + value: "shipments", + label: ( + + + Shipments + + {bookingItems.length} + + + ), + }, + { + value: "contracts", + label: ( + + + Contracts + + {contractItems.length} + + + ), + }, + ]} + /> + + {isLoading ? ( + + + + ) : tab === "contracts" ? ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. + + ) : ( + contractItems.map((c) => ( {c.reference} - {c.tradeDirection} · {c.status} + {c.tradeDirection} · {prettyStatus(c.status)} @@ -61,15 +121,24 @@ export default function GlDjiboutiClearanceListPage() { - ))} - {bookingItems.map((b) => ( + )) + )} + + ) : ( + + {bookingItems.length === 0 ? ( + + No shipment bookings awaiting a Djibouti action. + + ) : ( + bookingItems.map((b) => ( navigate(`/dashboard/clearance/${b.id}`)} + onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)} > @@ -80,7 +149,8 @@ export default function GlDjiboutiClearanceListPage() {
{b.reference} - {b.tradeDirection} · {b.status} + {b.tradeDirection} · {prettyStatus(b.status)} + {b.company?.name ? ` · ${b.company.name}` : ""}
@@ -92,11 +162,11 @@ export default function GlDjiboutiClearanceListPage() {
- ))} - - )} -
- )} + )) + )} +
+ )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index cff41ede2..e2c0fefb5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -140,7 +140,7 @@ export default function PassengersPage() { ), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 33f39d87e..ad10052fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -166,7 +166,10 @@ export default function TariffRatesPage() { }, { key: 'coachType', label: 'Coach Type', - render: (c: any) => {c.coachType?.name || c.coachTypeId}, + render: (c: any) => { + const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); + return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}; + }, }, { key: 'bedPosition', label: 'Bed Position', diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index e0c7122e3..c737da4de 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -343,15 +343,12 @@ export default function TicketsPage() { key: 'contact', label: 'Contact', render: (ticket: any) => { - const phone = ticket.booking?.passenger?.phone || 'N/A'; - const email = ticket.booking?.passenger?.email || 'N/A'; - + const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—'; + const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—'; return (
{phone}
-
-
{email}
-
+
{email}
); }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts index f7191e83b..f94e3d05f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts @@ -10,7 +10,10 @@ export const passengersApi = { if (filters?.role) params.append('role', filters.role); if (filters?.page) params.append('page', filters.page.toString()); if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); - + if ((filters as any)?.gender) params.append('gender', (filters as any).gender); + if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality); + if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom); + if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo); return apiClient.get>(`/passengers?${params.toString()}`); }, @@ -21,4 +24,8 @@ export const passengersApi = { update: (id: string, data: Partial) => { return apiClient.patch(`/passengers/${id}`, data); }, + + delete: (id: string, cascade = false) => { + return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`); + }, }; 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 ceb54acd9..3e9cecbc7 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 @@ -72,10 +72,10 @@ export default function PaymentPage() { // split equally across both legs. This guarantees leg totals are consistent with the // per-passenger breakdown rows and the overall reviewed total. const outboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; const inboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display @@ -307,11 +307,11 @@ export default function PaymentPage() {
Outbound - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
Return - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)} 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 33609ba0e..fa52aad32 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 @@ -293,7 +293,17 @@ export default function ResultsPage() { // For round trip inbound, proceed with both schedules if (isRoundTrip && !isOutbound) { - setInboundSchedule(scheduleData); + // Mirror the outbound's coachTypes (fares) onto the inbound schedule so the + // return seat selection page shows the same prices as the outbound leg. + const inboundScheduleData = outboundScheduleData + ? { + ...scheduleData, + baseFareAdult: outboundScheduleData.baseFareAdult, + baseFareChild: outboundScheduleData.baseFareChild, + coachTypes: outboundScheduleData.coachTypes, + } + : scheduleData; + setInboundSchedule(inboundScheduleData); setSelectedSchedule(outboundScheduleData); // Set primary as outbound } else { // For one-way 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 cf14cc87c..57b098dc1 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 @@ -445,7 +445,9 @@ export default function ReviewPage() { const fareMinor = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? pkgFallback)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); - return { fareMinor, isFree: isFreeChild }; + const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined; + const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined; + return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor }; }); setReviewedTotal(computedTotal, passengerFares); @@ -560,6 +562,14 @@ export default function ReviewPage() { const isFreeChild = isPackageBooking ? isPkgFreeChild(i) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + + // Per-leg fares for round trips + const outboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null)) + : null; + const inboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null)) + : null; const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) @@ -582,6 +592,19 @@ export default function ReviewPage() { {formatFare(passengerTotal, displayCurrency)} + {/* Round-trip: show outbound + inbound breakdown */} + {isRoundTrip && !isFreeChild && ( +
+
+ ↗ Outbound + {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'} +
+
+ ↙ Return + {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'} +
+
+ )} ); })} 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 f8fd23609..ccb953267 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 @@ -451,16 +451,15 @@ export default function SeatsPage() { // 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 newCoachTypeId = matchedType.coachTypeId || matchedType.coachId; const updatedSchedule = { ...(currentSchedule as any), selectedCoachTypeId: newCoachTypeId, selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "", selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "", - selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "", + seatClassName: matchedType.coachTypeName || coach.coachTypeName || "", baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, }; @@ -921,7 +920,7 @@ export default function SeatsPage() { const positionLabel = newSeat?.bedPosition ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` : "This seat"; - const legMultiplier = isRoundTrip ? 2 : 1; + const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; setModalState({ isOpen: true, @@ -1328,15 +1327,16 @@ export default function SeatsPage() { useEffect(() => { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } else { if (!selectedSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } }, [ isRoundTrip, + isPackageBooking, selectedSchedule, outboundSchedule, inboundSchedule, @@ -1712,7 +1712,7 @@ export default function SeatsPage() { if ( isRoundTrip - ? !outboundSchedule || !inboundSchedule || !passengers.length + ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length : !selectedSchedule || !passengers.length ) return null; 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 32e3f1178..bb839906d 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 @@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ if (!map.has(key)) { map.set(key, { coachTypeId: ct?.id ?? key, - coachTypeName: ct?.name ?? tier.seatType, + coachTypeName: ct?.name ?? tier.label ?? tier.seatType, coachTypeCode: ct?.code ?? '', coachTypeType: ct?.type ?? 'passenger', tiers: [], @@ -270,90 +270,121 @@ function PriceTiersPanel({ } return ( -
-

Select Coach Type

- {groups.map((group) => { - const CoachIcon = getCoachIcon(group.coachTypeType); - const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); - const isSelected = selectedId === group.coachTypeId; - return ( -
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} - > - {/* Coach type header */} -
-
- -
-
-

- {formatCoachTypeLabel(group.coachTypeType)} -

-

- From {formatPrice(group.minPrice * priceMultiplier, group.currency)} - {allSoldOut && · Sold out} -

-
+
+

Choose Coach Type

+
+ {groups.map((group, index) => { + const CoachIcon = getCoachIcon(group.coachTypeType); + const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); + const isSelected = selectedId === group.coachTypeId; + return ( +
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + onKeyDown={(e) => { + 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' + : 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` }} + > + {/* Radio indicator */} {!allSoldOut && ( -
- {isSelected && } -
+ + {isSelected && } + )} -
- {/* All available classes for this coach type */} -
- {group.tiers.map((tier) => { - const soldOut = tier.availableSeats === 0; - return ( -
-
-
-

{tier.seatType.trim()}

-

- {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} - {soldOut ? ( - Sold out - ) : ( - {tier.availableSeats} left - )} -

+
+
+
+ +
+ +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+ {allSoldOut && ( + Sold out + )} +
+ From + + {((group.minPrice * priceMultiplier) / 100).toFixed(2)} + + {group.currency}
- ); - })} -
+
- {/* Book Now — only when this group is selected */} - {isSelected && !allSoldOut && ( -
- + {/* Class options — always visible, matching results page style */} + {group.tiers.length > 0 && ( +
+
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+ {tier.seatType.trim()} + {soldOut ? ( + Sold out + ) : ( +
+ + {((tier.priceMinor * priceMultiplier) / 100).toFixed(2)} + + {tier.currency} +
+ )} +
+ ); + })} +
+
+ )} + + {!isSelected && !allSoldOut && ( +

+ Click to select this coach +

+ )} + + {isSelected && !allSoldOut && ( + + )}
- )} -
- ); - })} +
+ ); + })} +
+
); } @@ -407,7 +438,7 @@ function PassengerCountModal({

Coach type

-

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

+

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

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

@@ -416,26 +447,26 @@ function PassengerCountModal({ { 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}

-

{sub}

-
-
- - {value} - -
+
+
+

{label}

+

{sub}

- ))} +
+ + {value} + +
+
+ ))} {freeChildren > 0 && (
@@ -457,15 +488,14 @@ function PassengerCountModal({ {/* Departure Station */}
-