mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge pull request #468 from Tria-plc/alpha
Merge request for UAT issues resolution
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable: add package_departure_station_id to Booking
|
||||||
|
ALTER TABLE "passenger"."Booking"
|
||||||
|
ADD COLUMN "packageDepartureStationId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: add package_departure_station_id to PackageBooking
|
||||||
|
ALTER TABLE "passenger"."PackageBooking"
|
||||||
|
ADD COLUMN "packageDepartureStationId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey: Booking -> Station
|
||||||
|
ALTER TABLE "passenger"."Booking"
|
||||||
|
ADD CONSTRAINT "Booking_packageDepartureStationId_fkey"
|
||||||
|
FOREIGN KEY ("packageDepartureStationId")
|
||||||
|
REFERENCES "passenger"."Station"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey: PackageBooking -> Station
|
||||||
|
ALTER TABLE "passenger"."PackageBooking"
|
||||||
|
ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey"
|
||||||
|
FOREIGN KEY ("packageDepartureStationId")
|
||||||
|
REFERENCES "passenger"."Station"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -330,7 +330,9 @@ model Station {
|
|||||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||||
stopTimes TripStopTime[]
|
stopTimes TripStopTime[]
|
||||||
crowdSignals StationCrowdSignal[]
|
crowdSignals StationCrowdSignal[]
|
||||||
|
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
|
||||||
|
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
|
||||||
@@index([city, countryCode])
|
@@index([city, countryCode])
|
||||||
@@index([sequence])
|
@@index([sequence])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -541,6 +543,7 @@ model Booking {
|
|||||||
promoCode String?
|
promoCode String?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
paymentReminderSentAt DateTime?
|
paymentReminderSentAt DateTime?
|
||||||
|
packageDepartureStationId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
@@ -548,6 +551,7 @@ model Booking {
|
|||||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||||
package TravelPackage? @relation(fields: [packageId], references: [id])
|
package TravelPackage? @relation(fields: [packageId], references: [id])
|
||||||
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
||||||
|
departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||||
seats BookingSeat[]
|
seats BookingSeat[]
|
||||||
paymentIntent PaymentIntent?
|
paymentIntent PaymentIntent?
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
@@ -1479,10 +1483,12 @@ model PackageBooking {
|
|||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
packageDepartureStationId String?
|
||||||
|
|
||||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||||
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||||
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||||
|
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||||
passengers PackageBookingPassenger[]
|
passengers PackageBookingPassenger[]
|
||||||
paymentIntent PackagePaymentIntent?
|
paymentIntent PackagePaymentIntent?
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ export class PackagesController {
|
|||||||
return this.service.getBookingByRef(ref);
|
return this.service.getBookingByRef(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@IsPublic()
|
||||||
|
@UseGuards(OptionalJwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
|
||||||
|
book(@Body() dto: BookPackageDto, @Request() req: any) {
|
||||||
|
return this.service.book(dto, req.user?.passengerId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/booking-context')
|
@Get(':id/booking-context')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
|
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
|
||||||
@@ -177,12 +186,4 @@ export class PackagesController {
|
|||||||
return this.service.deleteTier(tierId);
|
return this.service.deleteTier(tierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('book')
|
|
||||||
@IsPublic()
|
|
||||||
@UseGuards(OptionalJwtGuard)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
|
|
||||||
book(@Body() dto: BookPackageDto, @Request() req: any) {
|
|
||||||
return this.service.book(dto, req.user?.passengerId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ export class SeatsService {
|
|||||||
const resolvedBedPosition = isBedCoach
|
const resolvedBedPosition = isBedCoach
|
||||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||||
: s.bedPosition;
|
: s.bedPosition;
|
||||||
|
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
seatNumber: s.seatNumber,
|
seatNumber: s.seatNumber,
|
||||||
label: s.seatNumber,
|
label: s.seatNumber,
|
||||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
status: effectiveStatus,
|
||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
row: s.row,
|
row: s.row,
|
||||||
col: s.col,
|
col: s.col,
|
||||||
@@ -245,11 +246,16 @@ export class SeatsService {
|
|||||||
holdFrom === undefined || holdTo === undefined ||
|
holdFrom === undefined || holdTo === undefined ||
|
||||||
(holdFrom < reqTo && reqFrom < holdTo);
|
(holdFrom < reqTo && reqFrom < holdTo);
|
||||||
|
|
||||||
if (!legsOverlap) continue;
|
|
||||||
|
|
||||||
// Check direction conflict
|
// Check direction conflict
|
||||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
||||||
if (!directionsConflict) continue;
|
|
||||||
|
if (!legsOverlap || !directionsConflict) {
|
||||||
|
// This hold does not conflict with the requested leg/direction.
|
||||||
|
// Explicitly mark AVAILABLE so the DB's HELD status (set by the
|
||||||
|
// opposing-direction hold) does not bleed through via the fallback.
|
||||||
|
if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
statusMap.set(seatId, 'HELD');
|
statusMap.set(seatId, 'HELD');
|
||||||
}
|
}
|
||||||
@@ -653,7 +659,7 @@ export class SeatsService {
|
|||||||
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
|
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||||
const totalSeats = seats.length;
|
const totalSeats = seats.length;
|
||||||
const unavailable = seats.filter(s => {
|
const unavailable = seats.filter(s => {
|
||||||
const status = effectiveStatuses.get(s.id) ?? s.status;
|
const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||||
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
|
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
@@ -665,8 +671,8 @@ export class SeatsService {
|
|||||||
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
|
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
|
||||||
totalSeats,
|
totalSeats,
|
||||||
availableSeats: totalSeats - unavailable,
|
availableSeats: totalSeats - unavailable,
|
||||||
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length,
|
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length,
|
||||||
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length,
|
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -861,11 +867,21 @@ export class SeatsService {
|
|||||||
if (expired.length === 0) return;
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
||||||
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
|
|
||||||
await this.prisma.seat.updateMany({
|
// Only reset seats that have no remaining active holds
|
||||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
const stillHeld = await this.prisma.seatHold.findMany({
|
||||||
data: { status: 'AVAILABLE' },
|
where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
|
||||||
|
select: { seatIds: true },
|
||||||
});
|
});
|
||||||
|
const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
|
||||||
|
const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
|
||||||
|
|
||||||
|
if (toRelease.length > 0) {
|
||||||
|
await this.prisma.seat.updateMany({
|
||||||
|
where: { id: { in: toRelease }, status: 'HELD' },
|
||||||
|
data: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
|
|||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { bookingsApi, apiClient } from '@/lib/api';
|
import { bookingsApi, apiClient } from '@/lib/api';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
|
||||||
import { BookingFilters } from '@/types';
|
import { BookingFilters } from '@/types';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
@@ -150,10 +150,39 @@ function BookingsPageContent() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
{booking.isPackageBooking
|
||||||
|
? <div className="text-xs text-muted-foreground">Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div>
|
||||||
|
: <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'trip',
|
||||||
|
label: 'Trip',
|
||||||
|
render: (booking: any) => {
|
||||||
|
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
|
const returnDeparture = booking?.returnSchedule?.departureAt;
|
||||||
|
console.log(JSON.stringify(booking.packageId));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{booking.schedule?.originStation?.name || 'N/A'} → {booking.schedule?.destinationStation?.name || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{!isRoundTrip ? (
|
||||||
|
<span>{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'}</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} ·
|
||||||
|
{returnDeparture ? formatDateTimeShort(returnDeparture) : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'passengerNames', label: 'Names',
|
key: 'passengerNames', label: 'Names',
|
||||||
render: (booking: any) => {
|
render: (booking: any) => {
|
||||||
@@ -200,14 +229,6 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'passengerCount', label: 'Passengers',
|
|
||||||
render: (booking: any) => {
|
|
||||||
const adults = booking.adultCount || 0, children = booking.childCount || 0;
|
|
||||||
if (!adults && !children) return '—';
|
|
||||||
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'paymentStatus', label: 'Payment',
|
key: 'paymentStatus', label: 'Payment',
|
||||||
render: (booking: any) => (
|
render: (booking: any) => (
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
title: 'Tourism',
|
title: 'Tourism',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
|
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
|
||||||
// { name: 'Pkg Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
|
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
|
||||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
|
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
const handleNewBooking = () => {
|
const handleNewBooking = () => {
|
||||||
clearBooking();
|
clearBooking();
|
||||||
router.push('/booking/search');
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ export default function SeatsPage() {
|
|||||||
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
||||||
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
||||||
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
||||||
|
// Outbound seat IDs locked in after the outbound hold — used to prevent the
|
||||||
|
// same physical seat being picked again on the inbound leg.
|
||||||
|
|
||||||
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
|
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
|
||||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||||
const [currentJourneyType, setCurrentJourneyType] = useState<
|
const [currentJourneyType, setCurrentJourneyType] = useState<
|
||||||
@@ -170,9 +173,12 @@ export default function SeatsPage() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType],
|
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`;
|
const scheduleForMap = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
||||||
|
const originId = (scheduleForMap as any)?.originStationId || searchCriteria?.originStationId;
|
||||||
|
const destinationId = (scheduleForMap as any)?.destinationStationId || searchCriteria?.destinationStationId;
|
||||||
|
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}${originId ? `&originStationId=${originId}` : ''}${destinationId ? `&destinationStationId=${destinationId}` : ''}`;
|
||||||
|
|
||||||
const response = await apiClient.get(endpoint);
|
const response = await apiClient.get(endpoint);
|
||||||
|
|
||||||
@@ -333,7 +339,7 @@ export default function SeatsPage() {
|
|||||||
return seats;
|
return seats;
|
||||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||||
|
|
||||||
// Seats already claimed by any passenger in this journey leg
|
// Seats already claimed by any passenger in this journey leg, plus outbound
|
||||||
const assignedSeatIds = useMemo(
|
const assignedSeatIds = useMemo(
|
||||||
() => new Set(Object.values(passengerSeatMap)),
|
() => new Set(Object.values(passengerSeatMap)),
|
||||||
[passengerSeatMap],
|
[passengerSeatMap],
|
||||||
|
|||||||
@@ -298,16 +298,19 @@ function PassengerCountModal({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
priceMultiplier,
|
priceMultiplier,
|
||||||
|
stations,
|
||||||
}: {
|
}: {
|
||||||
tier: PriceTier;
|
tier: PriceTier;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (adultCount: number, childCount: number) => void;
|
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
priceMultiplier: number;
|
priceMultiplier: number;
|
||||||
|
stations: Station[];
|
||||||
}) {
|
}) {
|
||||||
const [adultCount, setAdultCount] = useState(1);
|
const [adultCount, setAdultCount] = useState(1);
|
||||||
const [childCount, setChildCount] = useState(0);
|
const [childCount, setChildCount] = useState(0);
|
||||||
|
const [departureStationId, setDepartureStationId] = useState('');
|
||||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||||
const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO);
|
const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO);
|
||||||
const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier;
|
const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier;
|
||||||
@@ -318,7 +321,7 @@ function PassengerCountModal({
|
|||||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||||
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
|
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers</h2>
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers & boarding station selection</h2>
|
||||||
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
<X className="w-4 h-4 text-gray-500" />
|
<X className="w-4 h-4 text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
@@ -368,7 +371,28 @@ function PassengerCountModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
|
{/* Departure Station */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
||||||
|
Departure Station
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={departureStationId}
|
||||||
|
onChange={(e) => setDepartureStationId(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||||
|
>
|
||||||
|
<option value="">Select your boarding station</option>
|
||||||
|
{stations.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name.trim()} ({s.code})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">For informational purposes — pricing remains fixed regardless of boarding point.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => {
|
||||||
|
const station = stations.find(s => s.id === departureStationId);
|
||||||
|
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||||
|
}}
|
||||||
disabled={loading || adultCount + childCount < 1}
|
disabled={loading || adultCount + childCount < 1}
|
||||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
||||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||||
@@ -400,11 +424,18 @@ export default function PackageDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: stationsData } = useQuery({
|
||||||
|
queryKey: ["stations-simple"],
|
||||||
|
queryFn: async () => (await apiClient.get(`/stations?pageSize=100`)) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stations: Station[] = Array.isArray(stationsData) ? stationsData : stationsData?.items || stationsData?.data?.items || [];
|
||||||
|
|
||||||
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
||||||
|
|
||||||
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
||||||
|
|
||||||
const handleBookNow = async (adultCount: number, childCount: number) => {
|
const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => {
|
||||||
if (!selectedTier || !pkg) return;
|
if (!selectedTier || !pkg) return;
|
||||||
setBookingContextLoading(true);
|
setBookingContextLoading(true);
|
||||||
setBookingContextError(null);
|
setBookingContextError(null);
|
||||||
@@ -467,7 +498,7 @@ export default function PackageDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
||||||
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name);
|
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName);
|
||||||
|
|
||||||
router.push("/booking/passengers");
|
router.push("/booking/passengers");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -522,6 +553,7 @@ export default function PackageDetailPage() {
|
|||||||
loading={bookingContextLoading}
|
loading={bookingContextLoading}
|
||||||
error={bookingContextError}
|
error={bookingContextError}
|
||||||
priceMultiplier={isRoundTripPkg ? 2 : 1}
|
priceMultiplier={isRoundTripPkg ? 2 : 1}
|
||||||
|
stations={stations}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ interface BookingState {
|
|||||||
packageName: string | null;
|
packageName: string | null;
|
||||||
priceTierId: string | null;
|
priceTierId: string | null;
|
||||||
packageTierPriceMinor: number | null;
|
packageTierPriceMinor: number | null;
|
||||||
|
packageDepartureStationId: string | null;
|
||||||
|
packageDepartureStationName: string | null;
|
||||||
|
|
||||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||||
@@ -100,7 +102,7 @@ interface BookingState {
|
|||||||
setPaymentMethod: (method: string) => void;
|
setPaymentMethod: (method: string) => void;
|
||||||
setCreateAccount: (create: boolean) => void;
|
setCreateAccount: (create: boolean) => void;
|
||||||
setPassengerId: (id: string | null) => void;
|
setPassengerId: (id: string | null) => void;
|
||||||
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string) => void;
|
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
|
||||||
clearBooking: () => void;
|
clearBooking: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +123,11 @@ export const useBookingStore = create<BookingState>()(persist(
|
|||||||
packageName: null,
|
packageName: null,
|
||||||
priceTierId: null,
|
priceTierId: null,
|
||||||
packageTierPriceMinor: null,
|
packageTierPriceMinor: null,
|
||||||
|
packageDepartureStationId: null,
|
||||||
|
packageDepartureStationName: null,
|
||||||
|
|
||||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||||
setPackageContext: (packageId, priceTierId, priceMinor, packageName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor }),
|
setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }),
|
||||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||||
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
||||||
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
||||||
@@ -150,6 +154,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
|||||||
packageName: null,
|
packageName: null,
|
||||||
priceTierId: null,
|
priceTierId: null,
|
||||||
packageTierPriceMinor: null,
|
packageTierPriceMinor: null,
|
||||||
|
packageDepartureStationId: null,
|
||||||
|
packageDepartureStationName: null,
|
||||||
}),
|
}),
|
||||||
} as BookingState)),
|
} as BookingState)),
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user