mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
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")
|
||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||
stopTimes TripStopTime[]
|
||||
crowdSignals StationCrowdSignal[]
|
||||
crowdSignals StationCrowdSignal[]
|
||||
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
|
||||
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
|
||||
@@index([city, countryCode])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
@@ -541,6 +543,7 @@ model Booking {
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
paymentReminderSentAt DateTime?
|
||||
packageDepartureStationId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
@@ -548,6 +551,7 @@ model Booking {
|
||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||
package TravelPackage? @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
||||
departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
tickets Ticket[]
|
||||
@@ -1479,10 +1483,12 @@ model PackageBooking {
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
packageDepartureStationId String?
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||
passengers PackageBookingPassenger[]
|
||||
paymentIntent PackagePaymentIntent?
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.mod
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { AppReleasesModule } from './modules/app-releases/app-releases.module';
|
||||
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
|
||||
import { SegmentFareSeeder } from './seed/segment-fare.seeder';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -132,6 +134,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module';
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
AppReleasesModule,
|
||||
ConfigurableFareModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
@@ -139,6 +142,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module';
|
||||
DynamicThrottlerGuard,
|
||||
EdrPassengerOrgSeeder,
|
||||
PassengerStaffUsersSeeder,
|
||||
SegmentFareSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -147,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||
private readonly segmentFareSeeder: SegmentFareSeeder,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -165,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
} catch (err) {
|
||||
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await this.segmentFareSeeder.run();
|
||||
} catch (err) {
|
||||
this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,8 +341,9 @@ export class BookingsService {
|
||||
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
|
||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true,
|
||||
packageName: b.package?.name, packageCode: b.package?.code,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||
isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
|
||||
tierLabel: b.priceTier?.label ?? null,
|
||||
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
|
||||
createdAt: b.createdAt, passenger: null,
|
||||
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
|
||||
@@ -372,6 +373,8 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
package: { select: { id: true, name: true, code: true } },
|
||||
priceTier: { select: { id: true, label: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
@@ -415,6 +418,10 @@ export class BookingsService {
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
packageId: booking.packageId ?? null,
|
||||
priceTierId: (booking as any).priceTierId ?? null,
|
||||
packageName: (booking as any).package?.name ?? null,
|
||||
packageCode: (booking as any).package?.code ?? null,
|
||||
tierLabel: (booking as any).priceTier?.label ?? null,
|
||||
isPackageBooking: !!booking.packageId,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
adultCount: booking.adultCount,
|
||||
@@ -446,9 +453,11 @@ export class BookingsService {
|
||||
contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE',
|
||||
packageId: b.packageId,
|
||||
priceTierId: b.priceTierId,
|
||||
isPackageBooking: true,
|
||||
packageName: b.package?.name,
|
||||
packageCode: b.package?.code,
|
||||
tierLabel: b.priceTier?.label ?? null,
|
||||
returnLegStatus: null,
|
||||
adultCount: b.passengerCount,
|
||||
childCount: 0,
|
||||
@@ -518,20 +527,18 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare (first child encountered)
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free.
|
||||
let freeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
|
||||
} else {
|
||||
// Child: first child is free, subsequent children pay full fare
|
||||
if (!freeChildUsed) {
|
||||
fareMinor = 0;
|
||||
freeChildUsed = true;
|
||||
} else {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
@@ -661,34 +668,27 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare for outbound and return legs
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free per leg.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
|
||||
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
|
||||
} else {
|
||||
// Child fare for outbound
|
||||
if (!outboundFreeChildUsed) {
|
||||
outboundFareMinor = 0;
|
||||
outboundFreeChildUsed = true;
|
||||
} else {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for return
|
||||
if (!returnFreeChildUsed) {
|
||||
returnFareMinor = 0;
|
||||
returnFreeChildUsed = true;
|
||||
} else {
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
@@ -1231,16 +1231,20 @@ export class BookingsService {
|
||||
childCount: number,
|
||||
) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
|
||||
const passengerCount = adultCount + childCount;
|
||||
const totalBaseFareMinor = tier.priceMinor * passengerCount;
|
||||
// For round-trip packages the caller splits the tier price across legs, so
|
||||
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare.
|
||||
const childFareMinor = Math.round(tier.priceMinor * 0.1);
|
||||
const adultFareMinor = tier.priceMinor * adultCount;
|
||||
const childTotalMinor = childFareMinor * childCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
|
||||
return {
|
||||
baseFareMinor: tier.priceMinor,
|
||||
adultCount,
|
||||
adultFareMinor: tier.priceMinor * adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: 0,
|
||||
paidChildrenCount: childCount,
|
||||
childFareMinor: tier.priceMinor * childCount,
|
||||
childFareMinor: childTotalMinor,
|
||||
totalBaseFareMinor,
|
||||
discountMinor: 0,
|
||||
loyaltyRedemptionMinor: 0,
|
||||
|
||||
@@ -144,6 +144,12 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
||||
@IsOptional() @IsString() deviceId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' })
|
||||
@IsOptional() @IsString() packageId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@IsOptional() @IsString() priceTierId?: string;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -144,21 +144,34 @@ export class GuestBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate fare
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(
|
||||
dto.scheduleId,
|
||||
dto.seatClassId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
primaryNationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
|
||||
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
||||
let baseFareMinor: number;
|
||||
let paidChildrenCount: number;
|
||||
let childUnitFare: number;
|
||||
|
||||
if (isPackageOneway) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||
baseFareMinor = tier.priceMinor;
|
||||
paidChildrenCount = childCount;
|
||||
childUnitFare = Math.round(baseFareMinor * 0.1);
|
||||
} else {
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
baseFareMinor = await this.getBaseFare(
|
||||
dto.scheduleId,
|
||||
dto.seatClassId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
primaryNationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
paidChildrenCount = Math.max(0, childCount - 1);
|
||||
childUnitFare = baseFareMinor;
|
||||
}
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const childFareMinor = childUnitFare * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -217,6 +230,7 @@ export class GuestBookingService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
@@ -231,7 +245,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
@@ -258,7 +272,7 @@ export class GuestBookingService {
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
@@ -366,18 +380,36 @@ export class GuestBookingService {
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
// Calculate fares for both legs
|
||||
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
|
||||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
|
||||
let outboundBaseFare: number;
|
||||
let returnBaseFare: number;
|
||||
let paidChildrenCount: number;
|
||||
let outboundChildUnitFare: number;
|
||||
let returnChildUnitFare: number;
|
||||
|
||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
||||
if (isPackageRoundTrip) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
|
||||
const halfMinor = Math.round(tier.priceMinor / 2);
|
||||
outboundBaseFare = halfMinor;
|
||||
returnBaseFare = tier.priceMinor - halfMinor;
|
||||
paidChildrenCount = childCount;
|
||||
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
|
||||
returnChildUnitFare = Math.round(returnBaseFare * 0.1);
|
||||
} else {
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
[outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||
]);
|
||||
paidChildrenCount = Math.max(0, childCount - 1);
|
||||
outboundChildUnitFare = outboundBaseFare;
|
||||
returnChildUnitFare = returnBaseFare;
|
||||
}
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
|
||||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -423,6 +455,7 @@ export class GuestBookingService {
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
@@ -440,7 +473,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
@@ -455,7 +488,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -484,7 +517,7 @@ export class GuestBookingService {
|
||||
returnBaseFareMinor: returnBaseFare,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
|
||||
@@ -329,7 +329,7 @@ export class ConfigurableFareService {
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const freeChildren = Math.min(childCount, adultCount);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
|
||||
@@ -131,7 +131,7 @@ export class FareEngineService {
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
const freeChildrenCount = Math.min(childCount, adultCount);
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
|
||||
|
||||
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||
// First child is free, but pays premium and insurance
|
||||
|
||||
@@ -91,6 +91,15 @@ export class PackagesController {
|
||||
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')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
|
||||
@@ -177,12 +186,4 @@ export class PackagesController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,4 +118,10 @@ export class BookPackageDto {
|
||||
@ApiProperty({ type: [BookPackagePassengerDto] })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
|
||||
passengers: BookPackagePassengerDto[];
|
||||
|
||||
/** Number of adult passengers (≥5 years). Derived from passengers array if omitted. */
|
||||
@ApiPropertyOptional({ example: 2 }) @IsOptional() @IsInt() @Min(1) adultCount?: number;
|
||||
|
||||
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,32 @@ import { Currency } from '@prisma/client';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||
|
||||
/** Package-specific fare rules */
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
const PKG_MAX_CHILDREN = 2;
|
||||
const PKG_CHILD_FARE_RATIO = 0.1;
|
||||
|
||||
function calculatePackageFareBreakdown(
|
||||
priceMinor: number,
|
||||
isRoundTrip: boolean,
|
||||
adultCount: number,
|
||||
childCount: number,
|
||||
) {
|
||||
const multiplier = isRoundTrip ? 2 : 1;
|
||||
const adultFareMinor = priceMinor * multiplier;
|
||||
const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO);
|
||||
const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor;
|
||||
return { adultFareMinor, childFareMinor, totalMinor, multiplier };
|
||||
}
|
||||
|
||||
function deriveAge(dateOfBirth: string | Date): number {
|
||||
const today = new Date();
|
||||
const dob = new Date(dateOfBirth);
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
function generateRef(): string {
|
||||
return 'PKG-' + Array.from({ length: 6 }, () =>
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
|
||||
@@ -37,18 +63,22 @@ export class PackagesService {
|
||||
},
|
||||
});
|
||||
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
|
||||
|
||||
const tier = pkg.priceTiers.find(t => t.id === tierId);
|
||||
const tier = pkg.priceTiers.find((t) => t.id === tierId);
|
||||
if (!tier) throw new NotFoundException('Price tier not found');
|
||||
|
||||
const passengerCount = adultCount + childCount;
|
||||
if (passengerCount < 1) throw new BadRequestException('At least one passenger required');
|
||||
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
||||
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
||||
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
||||
|
||||
const passengerCount = adultCount + childCount;
|
||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||
if (passengerCount > remaining)
|
||||
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
|
||||
|
||||
const totalMinor = tier.priceMinor * passengerCount;
|
||||
const isRoundTrip = !!pkg.returnScheduleId;
|
||||
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
||||
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
||||
);
|
||||
|
||||
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
|
||||
let seatClassId: string | null = null;
|
||||
@@ -60,7 +90,6 @@ export class PackagesService {
|
||||
);
|
||||
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
|
||||
}
|
||||
// Fallback: use the first coach assignment's coachTypeId if no match found
|
||||
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
|
||||
const first = pkg.outboundSchedule.coachAssignments[0];
|
||||
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
|
||||
@@ -77,14 +106,19 @@ export class PackagesService {
|
||||
adultCount,
|
||||
childCount,
|
||||
passengerCount,
|
||||
pricePerPassengerMinor: tier.priceMinor,
|
||||
isRoundTrip,
|
||||
pricePerAdultMinor: adultFareMinor,
|
||||
pricePerChildMinor: childFareMinor,
|
||||
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
||||
maxAdults: PKG_MAX_ADULTS,
|
||||
maxChildren: PKG_MAX_CHILDREN,
|
||||
totalMinor,
|
||||
currency: tier.currency,
|
||||
remainingSeats: remaining,
|
||||
outboundSchedule: {
|
||||
scheduleId: pkg.outboundScheduleId,
|
||||
originStationId: pkg.originStationId,
|
||||
destinationStationId: pkg.destinationStationId,
|
||||
originStationId: pkg.outboundSchedule.originStationId,
|
||||
destinationStationId: pkg.outboundSchedule.destinationStationId,
|
||||
departureAt: pkg.outboundSchedule.departureAt,
|
||||
arrivalAt: pkg.outboundSchedule.arrivalAt,
|
||||
originStation: pkg.outboundSchedule.originStation,
|
||||
@@ -92,12 +126,12 @@ export class PackagesService {
|
||||
},
|
||||
returnSchedule: pkg.returnSchedule ? {
|
||||
scheduleId: pkg.returnScheduleId,
|
||||
originStationId: pkg.destinationStationId,
|
||||
destinationStationId: pkg.originStationId,
|
||||
originStationId: pkg.returnSchedule.originStationId,
|
||||
destinationStationId: pkg.returnSchedule.destinationStationId,
|
||||
departureAt: pkg.returnSchedule.departureAt,
|
||||
arrivalAt: pkg.returnSchedule.arrivalAt,
|
||||
originStation: pkg.returnSchedule.destinationStation,
|
||||
destinationStation: pkg.returnSchedule.originStation,
|
||||
originStation: pkg.returnSchedule.originStation,
|
||||
destinationStation: pkg.returnSchedule.destinationStation,
|
||||
} : null,
|
||||
includedServices: pkg.includedServices,
|
||||
busTransferIncluded: pkg.busTransferIncluded,
|
||||
@@ -170,7 +204,14 @@ export class PackagesService {
|
||||
where: { id },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
outboundSchedule: {
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
train: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
},
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
@@ -297,13 +338,30 @@ export class PackagesService {
|
||||
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
||||
if (!tier) throw new NotFoundException('Price tier not found');
|
||||
|
||||
const passengerCount = dto.passengers.length;
|
||||
// Derive adult/child counts from the passengers array (dateOfBirth-based)
|
||||
let adultCount = 0, childCount = 0;
|
||||
for (const p of dto.passengers) {
|
||||
if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++;
|
||||
else adultCount++;
|
||||
}
|
||||
// Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger)
|
||||
if (dto.adultCount !== undefined) adultCount = dto.adultCount;
|
||||
if (dto.childCount !== undefined) childCount = dto.childCount;
|
||||
|
||||
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
||||
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
||||
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
||||
|
||||
const passengerCount = adultCount + childCount;
|
||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||
if (passengerCount > remaining) {
|
||||
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
|
||||
}
|
||||
|
||||
const totalMinor = tier.priceMinor * passengerCount;
|
||||
const isRoundTrip = !!pkg.returnScheduleId;
|
||||
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
||||
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
||||
);
|
||||
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
|
||||
const displayTotalMinor =
|
||||
displayCurrency !== Currency.ETB
|
||||
@@ -354,7 +412,21 @@ export class PackagesService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return booking;
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
isRoundTrip,
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
childFareMinor,
|
||||
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getMyBookings(passengerId: string) {
|
||||
|
||||
@@ -50,8 +50,8 @@ export class CreateScheduleDto {
|
||||
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
|
||||
@@ -153,7 +153,7 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
|
||||
@@ -61,11 +61,12 @@ export class SeatsService {
|
||||
const resolvedBedPosition = isBedCoach
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
status: effectiveStatus,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
@@ -245,11 +246,16 @@ export class SeatsService {
|
||||
holdFrom === undefined || holdTo === undefined ||
|
||||
(holdFrom < reqTo && reqFrom < holdTo);
|
||||
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Check direction conflict
|
||||
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');
|
||||
}
|
||||
@@ -653,7 +659,7 @@ export class SeatsService {
|
||||
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||
const totalSeats = seats.length;
|
||||
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';
|
||||
}).length;
|
||||
|
||||
@@ -665,8 +671,8 @@ export class SeatsService {
|
||||
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
|
||||
totalSeats,
|
||||
availableSeats: totalSeats - unavailable,
|
||||
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length,
|
||||
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').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 === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -861,11 +867,21 @@ export class SeatsService {
|
||||
if (expired.length === 0) return;
|
||||
|
||||
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({
|
||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
|
||||
// Only reset seats that have no remaining active holds
|
||||
const stillHeld = await this.prisma.seatHold.findMany({
|
||||
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() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* SEGMENT-BASED SEAT RESERVATION EXAMPLE
|
||||
*
|
||||
* Demonstrates the complete flow for booking Addis Ababa → Dire Dawa
|
||||
* on the Addis Ababa → Djibouti route with segment-based seat management.
|
||||
*
|
||||
* Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6)
|
||||
* Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4)
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function exampleBookingFlow() {
|
||||
console.log('=== SEGMENT-BASED BOOKING FLOW ===\n');
|
||||
|
||||
const scheduleId = 'schedule_add_dji_001';
|
||||
const passengerId = 'passenger_kelemu';
|
||||
const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b'];
|
||||
const originStationId = 'st_ADD';
|
||||
const destinationStationId = 'st_DRE';
|
||||
|
||||
try {
|
||||
console.log('1. Checking seat availability...');
|
||||
const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId);
|
||||
console.log('Journey segments:', segments.map(s => `${s.fromName} → ${s.toName}`));
|
||||
|
||||
console.log('\n2. Holding seats...');
|
||||
const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId);
|
||||
console.log('Hold created:', holdResult);
|
||||
|
||||
console.log('\n3. Processing payment...');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
console.log('\n4. Confirming booking...');
|
||||
const bookingId = 'booking_' + Date.now();
|
||||
const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments);
|
||||
console.log('Booking confirmed:', confirmResult);
|
||||
|
||||
console.log('\n5. Simulating trip progress...');
|
||||
await simulateTripProgress(scheduleId, segments);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Booking flow error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) {
|
||||
const stopTimes = await prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
include: { station: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
|
||||
const originStop = stopTimes.find(st => st.stationId === originStationId);
|
||||
const destinationStop = stopTimes.find(st => st.stationId === destinationStationId);
|
||||
|
||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) {
|
||||
throw new Error('Invalid origin/destination');
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
|
||||
const fromStop = stopTimes.find(st => st.sequence === i);
|
||||
const toStop = stopTimes.find(st => st.sequence === i + 1);
|
||||
if (fromStop && toStop) {
|
||||
segments.push({
|
||||
fromStationId: fromStop.stationId,
|
||||
toStationId: toStop.stationId,
|
||||
fromSequence: fromStop.sequence,
|
||||
toSequence: toStop.sequence,
|
||||
fromName: fromStop.station.name,
|
||||
toName: toStop.station.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
console.log(' → Starting seat hold transaction...');
|
||||
|
||||
const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } });
|
||||
if (seats.length !== seatIds.length) throw new Error('Some seats not found');
|
||||
|
||||
for (const seat of seats) {
|
||||
if (seat.status !== 'AVAILABLE') {
|
||||
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
const seatHold = await tx.seatHold.create({
|
||||
data: { scheduleId, seatIds, passengerId, expiresAt },
|
||||
});
|
||||
|
||||
await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
||||
|
||||
console.log(' → Seats held successfully');
|
||||
return { holdId: seatHold.id, expiresAt, seats: seatIds.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
console.log(' → Starting booking confirmation transaction...');
|
||||
|
||||
const hold = await tx.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found');
|
||||
|
||||
const booking = await tx.booking.create({
|
||||
data: {
|
||||
id: bookingId,
|
||||
bookingRef: 'BK' + Date.now().toString().slice(-6),
|
||||
passengerId: hold.passengerId,
|
||||
scheduleId: hold.scheduleId,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: 45000,
|
||||
currency: 'ETB',
|
||||
},
|
||||
});
|
||||
|
||||
const journey = await tx.journey.create({
|
||||
data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' },
|
||||
});
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
await tx.journeySegment.create({
|
||||
data: {
|
||||
journeyId: journey.id,
|
||||
scheduleId: hold.scheduleId,
|
||||
segmentOrder: i + 1,
|
||||
seatId,
|
||||
departureStationId: segments[i].fromStationId,
|
||||
arrivalStationId: segments[i].toStationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } });
|
||||
}
|
||||
|
||||
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
|
||||
await tx.seatHold.delete({ where: { id: holdId } });
|
||||
|
||||
console.log(' → Booking confirmed successfully');
|
||||
return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) {
|
||||
console.log(' → Simulating trip progress...');
|
||||
|
||||
for (const segment of bookedSegments) {
|
||||
console.log(` → Train approaching ${segment.toName}...`);
|
||||
|
||||
await prisma.tripLiveStatus.upsert({
|
||||
where: { scheduleId },
|
||||
update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) },
|
||||
create: {
|
||||
scheduleId,
|
||||
state: 'EN_ROUTE',
|
||||
currentLocationLabel: segment.toName,
|
||||
progressPercent: Math.round((segment.toSequence / 4) * 100),
|
||||
delayMinutes: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (segment.toName === 'Dire Dawa') {
|
||||
console.log(' → Passengers reached destination, releasing seats...');
|
||||
await releaseSeatsAtStation(scheduleId, segment.toStationId);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseSeatsAtStation(scheduleId: string, stationId: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const completedSegments = await tx.journeySegment.findMany({
|
||||
where: { scheduleId, arrivalStationId: stationId },
|
||||
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
|
||||
});
|
||||
|
||||
const seatsToRelease: string[] = [];
|
||||
|
||||
for (const segment of completedSegments) {
|
||||
const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId);
|
||||
const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder));
|
||||
if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!);
|
||||
}
|
||||
|
||||
if (seatsToRelease.length > 0) {
|
||||
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
|
||||
console.log(` → Released ${seatsToRelease.length} seats at station`);
|
||||
}
|
||||
|
||||
return seatsToRelease;
|
||||
});
|
||||
}
|
||||
|
||||
async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) {
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
const activeBookings = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId,
|
||||
journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
||||
},
|
||||
});
|
||||
|
||||
return [...activeHolds, ...activeBookings];
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
exampleBookingFlow()
|
||||
.then(() => console.log('\n=== EXAMPLES COMPLETED ==='))
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
|
||||
export {
|
||||
exampleBookingFlow,
|
||||
getJourneySegments,
|
||||
holdSeatsTransaction,
|
||||
confirmBookingTransaction,
|
||||
simulateTripProgress,
|
||||
releaseSeatsAtStation,
|
||||
checkOverlappingReservations,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete,
|
||||
Param, Body, Query, UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
import { SegmentFareService } from './segment-fare.service';
|
||||
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||
|
||||
@ApiTags('Admin – Segment Fares')
|
||||
@Controller('admin/segment-fares')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN', 'SUPERVISOR')
|
||||
export class SegmentFareController {
|
||||
constructor(private readonly service: SegmentFareService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' })
|
||||
@ApiQuery({ name: 'routeId', required: false })
|
||||
findAll(@Query('routeId') routeId?: string) {
|
||||
return this.service.findAll(routeId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single segment fare rule' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||
create(@Body() dto: CreateSegmentFareDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString, IsInt, IsOptional, IsDateString, IsIn, Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSegmentFareDto {
|
||||
@ApiProperty({ example: 'route-uuid' })
|
||||
@IsString()
|
||||
routeId: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsInt() @Min(0)
|
||||
originStopSequence: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt() @Min(1)
|
||||
destinationStopSequence: number;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid' })
|
||||
@IsString()
|
||||
seatClassId: string;
|
||||
|
||||
@ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' })
|
||||
@IsInt() @Min(0)
|
||||
baseFareMinor: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] })
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'INTERNATIONAL'])
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB', default: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
validFrom: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validUntil?: string;
|
||||
}
|
||||
|
||||
export class UpdateSegmentFareDto {
|
||||
@ApiPropertyOptional({ example: 40000 })
|
||||
@IsOptional()
|
||||
@IsInt() @Min(0)
|
||||
baseFareMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validUntil?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SegmentFareService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(routeId?: string) {
|
||||
return this.prisma.segmentFareRule.findMany({
|
||||
where: routeId ? { routeId } : undefined,
|
||||
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||
orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rule = await this.prisma.segmentFareRule.findUnique({
|
||||
where: { id },
|
||||
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||
});
|
||||
if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
create(dto: CreateSegmentFareDto) {
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
routeId: dto.routeId,
|
||||
originStopSequence: dto.originStopSequence,
|
||||
destinationStopSequence: dto.destinationStopSequence,
|
||||
seatClassId: dto.seatClassId,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
nationality: dto.nationality ?? null,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
validFrom: new Date(dto.validFrom),
|
||||
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateSegmentFareDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
|
||||
...(dto.currency !== undefined && { currency: dto.currency }),
|
||||
...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }),
|
||||
...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service';
|
||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { TripProgressService } from './trip-progress.service';
|
||||
import { SegmentSeatsController } from './segments.controller';
|
||||
import { SegmentFareController } from './segment-fare.controller';
|
||||
import { SegmentFareService } from './segment-fare.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SegmentSeatsController],
|
||||
controllers: [SegmentSeatsController, SegmentFareController],
|
||||
providers: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService,
|
||||
PrismaService
|
||||
SegmentFareService,
|
||||
PrismaService,
|
||||
],
|
||||
exports: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService
|
||||
]
|
||||
TripProgressService,
|
||||
SegmentFareService,
|
||||
],
|
||||
})
|
||||
export class SegmentsModule {}
|
||||
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal file
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma.service';
|
||||
|
||||
const SEED_FLAG = 'SEED_SEGMENT_FARES';
|
||||
|
||||
/**
|
||||
* Seeds SegmentFareRule rows for every origin→destination pair on the
|
||||
* Addis Ababa–Djibouti route across all active seat classes.
|
||||
*
|
||||
* Skip-if-loaded: uses Prisma upsert on the unique constraint
|
||||
* (routeId, originStopSequence, destinationStopSequence, seatClassId, nationality).
|
||||
* Re-running is safe — existing rows are updated in-place.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SegmentFareSeeder {
|
||||
private readonly logger = new Logger(SegmentFareSeeder.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run() {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping segment fare seed — set ${SEED_FLAG}=true to enable`);
|
||||
return;
|
||||
}
|
||||
|
||||
const route = await this.prisma.route.findFirst({
|
||||
where: { active: true },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!route) {
|
||||
this.logger.warn('No active route found — skipping segment fare seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
if (!seatClasses.length) {
|
||||
this.logger.warn('No active seat classes found — skipping segment fare seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const stops = route.stops;
|
||||
const validFrom = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
// Rate table: ETB minor units per km, keyed by (nationalityType, bedPosition)
|
||||
// null bedPosition = regular seat
|
||||
const rateTable: Record<string, Record<string | 'null', number>> = {
|
||||
LOCAL: { null: 3000, UPPER: 4000, MIDDLE: 5500, LOWER: 6000 },
|
||||
INTERNATIONAL: { null: 6000, UPPER: 8000, MIDDLE: 11000, LOWER: 12000 },
|
||||
};
|
||||
|
||||
let upserted = 0;
|
||||
|
||||
for (const seatClass of seatClasses) {
|
||||
const natType = seatClass.nationalityType ?? 'LOCAL';
|
||||
const bedPos = seatClass.bedPosition ?? 'null';
|
||||
const ratePerKm = rateTable[natType]?.[bedPos] ?? rateTable['LOCAL']['null'];
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
for (let j = i + 1; j < stops.length; j++) {
|
||||
const origin = stops[i];
|
||||
const dest = stops[j];
|
||||
|
||||
// Approximate distance: sum of per-stop distanceKm if available,
|
||||
// otherwise fall back to sequence-gap × 50 km.
|
||||
let distanceKm = 0;
|
||||
for (let k = i; k < j; k++) {
|
||||
distanceKm += stops[k + 1].distanceKm ?? 50;
|
||||
}
|
||||
|
||||
const baseFareMinor = Math.round(distanceKm * ratePerKm);
|
||||
|
||||
await this.prisma.segmentFareRule.upsert({
|
||||
where: {
|
||||
routeId_originStopSequence_destinationStopSequence_seatClassId_nationality: {
|
||||
routeId: route.id,
|
||||
originStopSequence: origin.sequence,
|
||||
destinationStopSequence: dest.sequence,
|
||||
seatClassId: seatClass.id,
|
||||
nationality: natType,
|
||||
},
|
||||
},
|
||||
update: { baseFareMinor, validFrom },
|
||||
create: {
|
||||
routeId: route.id,
|
||||
originStopSequence: origin.sequence,
|
||||
destinationStopSequence: dest.sequence,
|
||||
seatClassId: seatClass.id,
|
||||
nationality: natType,
|
||||
baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
},
|
||||
});
|
||||
|
||||
upserted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Segment fare seed complete — ${upserted} rules upserted ` +
|
||||
`(${stops.length} stops × ${seatClasses.length} seat classes)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export default function AppReleasesPage() {
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setFormOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" isLoading={saveMutation.isPending}>
|
||||
<ActionButton type="submit" loading={saveMutation.isPending}>
|
||||
{editing ? 'Save Changes' : 'Create Release'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { bookingsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
|
||||
import { BookingFilters } from '@/types';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
@@ -150,10 +150,39 @@ function BookingsPageContent() {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
render: (booking: any) => {
|
||||
@@ -200,14 +229,6 @@ function BookingsPageContent() {
|
||||
</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',
|
||||
render: (booking: any) => (
|
||||
|
||||
@@ -289,7 +289,7 @@ export default function ClassesPage() {
|
||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare (ETB) *</label>
|
||||
<label className="label">Base Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function FareManagementPage() {
|
||||
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor', label: 'Base Fare (ETB)',
|
||||
key: 'baseFareMinor', label: 'Base Fare',
|
||||
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
@@ -214,7 +214,7 @@ export default function FareManagementPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Base Fare (ETB) *</label>
|
||||
<label className="label">Base Fare *</label>
|
||||
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
|
||||
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
|
||||
placeholder="e.g. 350.00" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import '@/styles/globals.css';
|
||||
import Providers from './providers';
|
||||
|
||||
@@ -12,10 +13,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Nonce set per-request by middleware; required for this inline script under the CSP.
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function PricingPage() {
|
||||
|
||||
const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({
|
||||
queryKey: ['segment-fares', selectedRoute],
|
||||
queryFn: () => (selectedRoute ? apiClient.get(`/schedules/routes/${selectedRoute}/segment-fares`) : Promise.resolve([])),
|
||||
queryFn: () => (selectedRoute ? apiClient.get(`/admin/segment-fares?routeId=${selectedRoute}`) : Promise.resolve([])),
|
||||
enabled: !!selectedRoute && tab === 'segment',
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ export default function PricingPage() {
|
||||
});
|
||||
|
||||
const createSegmentFareMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post(`/schedules/segment-fares`, data),
|
||||
mutationFn: (data: any) => apiClient.post(`/admin/segment-fares`, data),
|
||||
onSuccess: () => {
|
||||
refetchSegmentFares();
|
||||
resetSegmentForm();
|
||||
@@ -211,7 +211,7 @@ export default function PricingPage() {
|
||||
});
|
||||
|
||||
const updateSegmentFareMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.patch(`/schedules/segment-fares/${data.id}`, data),
|
||||
mutationFn: ({ id, ...data }: any) => apiClient.put(`/admin/segment-fares/${id}`, data),
|
||||
onSuccess: () => {
|
||||
refetchSegmentFares();
|
||||
setEditingFare(null);
|
||||
@@ -224,7 +224,7 @@ export default function PricingPage() {
|
||||
});
|
||||
|
||||
const deleteSegmentFareMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`),
|
||||
mutationFn: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
|
||||
onSuccess: () => {
|
||||
refetchSegmentFares();
|
||||
setDeleteConfirm({ isOpen: false, id: null });
|
||||
@@ -422,7 +422,7 @@ export default function PricingPage() {
|
||||
},
|
||||
{
|
||||
key: 'baseFare',
|
||||
label: 'Fare (ETB)',
|
||||
label: 'Fare',
|
||||
render: (fare: any) => {
|
||||
const minor = fare.baseFareMinor ?? fare.baseFare;
|
||||
if (minor == null) return <span className="text-muted-foreground">—</span>;
|
||||
@@ -495,7 +495,7 @@ export default function PricingPage() {
|
||||
},
|
||||
{
|
||||
key: 'baseFare',
|
||||
label: 'Fare (ETB)',
|
||||
label: 'Fare',
|
||||
render: (fare: any) => {
|
||||
const fareValue = fare.baseFare ?? fare.baseFareMinor;
|
||||
if (fareValue == null) return <span className="text-muted-foreground">—</span>;
|
||||
@@ -870,7 +870,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Fare (ETB) *</label>
|
||||
<label className="label">Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -1000,7 +1000,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Fare (ETB) *</label>
|
||||
<label className="label">Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
||||
@@ -9,6 +9,7 @@ import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Image from 'next/image';
|
||||
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
@@ -241,10 +242,14 @@ export default function TicketsPage() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
|
||||
'</body></html>'
|
||||
);
|
||||
w.document.close();
|
||||
// Drive printing from the opener rather than an inline <script> in the popup — the
|
||||
// about:blank window inherits this page's CSP, which blocks non-nonced inline scripts.
|
||||
w.focus();
|
||||
w.onafterprint = () => w.close();
|
||||
w.print();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ticket: any) => {
|
||||
@@ -844,10 +849,13 @@ export default function TicketsPage() {
|
||||
<SectionHeader title="QR Code" />
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
|
||||
<img
|
||||
<Image
|
||||
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
|
||||
alt={`QR Code for ${t.ticketNumber}`}
|
||||
className="w-48 h-48 object-contain"
|
||||
width={192}
|
||||
height={192}
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
title: 'Tourism',
|
||||
items: [
|
||||
{ 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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -458,6 +458,14 @@ export const excessBaggageApi = {
|
||||
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
|
||||
};
|
||||
|
||||
// Segment Fares API
|
||||
export const segmentFaresApi = {
|
||||
getAll: (routeId?: string) => apiClient.get<any>(`/admin/segment-fares${routeId ? `?routeId=${routeId}` : ''}`),
|
||||
create: (data: any) => apiClient.post<any>('/admin/segment-fares', data),
|
||||
update: (id: string, data: any) => apiClient.put<any>(`/admin/segment-fares/${id}`, data),
|
||||
remove: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
|
||||
};
|
||||
|
||||
// Route Coach Templates API
|
||||
export const routeCoachTemplatesApi = {
|
||||
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
|
||||
|
||||
@@ -2,21 +2,93 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/reset-password'];
|
||||
|
||||
/**
|
||||
* Build the Content-Security-Policy for a single request.
|
||||
*
|
||||
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
|
||||
* carrying this request's nonce (and scripts they load) may execute, which neutralises
|
||||
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
|
||||
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
|
||||
* request `Content-Security-Policy` header (see below); our own inline scripts read it
|
||||
* from the `x-nonce` request header in the root layout.
|
||||
*
|
||||
* `strict-dynamic` also covers the jsQR script the boarding scanner injects at runtime
|
||||
* (a trusted script's dynamically-created <script> is allowed), so no CDN host needs
|
||||
* allowlisting.
|
||||
*
|
||||
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
|
||||
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
|
||||
* plain-HTTP localhost keeps working.
|
||||
*/
|
||||
function buildCsp(nonce: string): string {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
|
||||
let apiOrigin = '';
|
||||
try {
|
||||
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
|
||||
} catch {
|
||||
apiOrigin = '';
|
||||
}
|
||||
|
||||
const scriptSrc = isProd
|
||||
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
|
||||
: `'self' 'unsafe-inline' 'unsafe-eval'`;
|
||||
|
||||
const connectSrc = isProd
|
||||
? `'self' ${apiOrigin}`.trim()
|
||||
: `'self' ${apiOrigin} ws: wss:`.trim();
|
||||
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`base-uri 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
|
||||
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
|
||||
`font-src 'self' data:`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`worker-src 'self' blob:`,
|
||||
`frame-src 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors 'none'`,
|
||||
...(isProd ? ['upgrade-insecure-requests'] : []),
|
||||
];
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = buildCsp(nonce);
|
||||
|
||||
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
|
||||
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
requestHeaders.set('Content-Security-Policy', csp);
|
||||
|
||||
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
let response: NextResponse;
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
response = render();
|
||||
} else {
|
||||
// Token is stored in localStorage (client-side only), so middleware can't
|
||||
// read it directly. We use a cookie set on login as the server-side signal.
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
response = token ? render() : NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
// Token is stored in localStorage (client-side only), so middleware can't
|
||||
// read it directly. We use a cookie set on login as the server-side signal.
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
response.headers.set('Content-Security-Policy', csp);
|
||||
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
|
||||
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Routes API</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
button { padding: 10px 20px; margin: 10px 0; cursor: pointer; }
|
||||
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
.route { border: 1px solid #ddd; padding: 10px; margin: 10px 0; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Routes API Test</h1>
|
||||
|
||||
<button onclick="fetchRoutes()">Fetch All Routes</button>
|
||||
<button onclick="deleteRoute()">Delete ADD-DDW Route</button>
|
||||
|
||||
<div id="output"></div>
|
||||
|
||||
<script>
|
||||
const API_URL = 'http://localhost:4000';
|
||||
const TOKEN = localStorage.getItem('token') || 'YOUR_JWT_TOKEN_HERE';
|
||||
|
||||
async function fetchRoutes() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/routes`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('Routes response:', data);
|
||||
|
||||
const output = document.getElementById('output');
|
||||
output.innerHTML = '<h2>Routes Found:</h2>';
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
output.innerHTML += `<p>Total routes: ${data.length}</p>`;
|
||||
data.forEach(route => {
|
||||
output.innerHTML += `
|
||||
<div class="route">
|
||||
<strong>${route.code}</strong> - ${route.name}<br>
|
||||
<small>ID: ${route.id}</small><br>
|
||||
<small>Active: ${route.active}</small><br>
|
||||
<small>Stops: ${route._count?.stops || route.stops?.length || 0}</small>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
output.innerHTML += '<pre>' + JSON.stringify(data, null, 2) + '</pre>';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('output').innerHTML =
|
||||
'<p style="color: red;">Error: ' + error.message + '</p>';
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoute() {
|
||||
const code = 'ADD-DDW';
|
||||
try {
|
||||
// First fetch to get the route ID
|
||||
const listResponse = await fetch(`${API_URL}/routes`, {
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
const routes = await listResponse.json();
|
||||
const route = routes.find(r => r.code === code);
|
||||
|
||||
if (!route) {
|
||||
alert('Route ADD-DDW not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Delete route ${route.code} - ${route.name}?`)) {
|
||||
const response = await fetch(`${API_URL}/routes/${route.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Route deleted successfully');
|
||||
fetchRoutes();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error: ' + JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fetch on load
|
||||
fetchRoutes();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,132 +0,0 @@
|
||||
// Test script for Stations CRUD operations
|
||||
// Run this in the browser console on the backoffice app
|
||||
|
||||
async function testStationsCRUD() {
|
||||
const API_URL = 'http://localhost:4000';
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
};
|
||||
|
||||
console.log('🧪 Testing Stations CRUD Operations...\n');
|
||||
|
||||
try {
|
||||
// 1. CREATE - Add a new station
|
||||
console.log('1️⃣ Testing CREATE Station...');
|
||||
const newStation = {
|
||||
code: 'TEST',
|
||||
name: 'Test Station',
|
||||
city: 'Test City',
|
||||
countryCode: 'ET',
|
||||
lat: '9.0320',
|
||||
lng: '38.7469',
|
||||
timezone: 'Africa/Addis_Ababa',
|
||||
isOperational: true
|
||||
};
|
||||
|
||||
const createResponse = await fetch(`${API_URL}/stations`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(newStation)
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`CREATE failed: ${createResponse.status} ${await createResponse.text()}`);
|
||||
}
|
||||
|
||||
const createdStation = await createResponse.json();
|
||||
console.log('✅ Station created:', createdStation);
|
||||
const stationId = createdStation.id || createdStation.data?.id;
|
||||
|
||||
if (!stationId) {
|
||||
throw new Error('No station ID returned from create');
|
||||
}
|
||||
|
||||
// 2. READ - Get the created station
|
||||
console.log('\n2️⃣ Testing READ Station...');
|
||||
const readResponse = await fetch(`${API_URL}/stations/${stationId}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!readResponse.ok) {
|
||||
throw new Error(`READ failed: ${readResponse.status}`);
|
||||
}
|
||||
|
||||
const readStation = await readResponse.json();
|
||||
console.log('✅ Station retrieved:', readStation);
|
||||
|
||||
// 3. UPDATE - Modify the station
|
||||
console.log('\n3️⃣ Testing UPDATE Station...');
|
||||
const updateData = {
|
||||
name: 'Test Station Updated',
|
||||
city: 'Test City Updated',
|
||||
isOperational: false
|
||||
};
|
||||
|
||||
const updateResponse = await fetch(`${API_URL}/stations/${stationId}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`UPDATE failed: ${updateResponse.status} ${await updateResponse.text()}`);
|
||||
}
|
||||
|
||||
const updatedStation = await updateResponse.json();
|
||||
console.log('✅ Station updated:', updatedStation);
|
||||
|
||||
// 4. LIST - Get all stations
|
||||
console.log('\n4️⃣ Testing LIST Stations...');
|
||||
const listResponse = await fetch(`${API_URL}/stations`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`LIST failed: ${listResponse.status}`);
|
||||
}
|
||||
|
||||
const stations = await listResponse.json();
|
||||
console.log('✅ Stations list retrieved:', stations);
|
||||
|
||||
// 5. DELETE - Remove the test station
|
||||
console.log('\n5️⃣ Testing DELETE Station...');
|
||||
const deleteResponse = await fetch(`${API_URL}/stations/${stationId}`, {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
throw new Error(`DELETE failed: ${deleteResponse.status} ${await deleteResponse.text()}`);
|
||||
}
|
||||
|
||||
console.log('✅ Station deleted successfully');
|
||||
|
||||
// 6. Verify deletion
|
||||
console.log('\n6️⃣ Verifying deletion...');
|
||||
const verifyResponse = await fetch(`${API_URL}/stations/${stationId}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (verifyResponse.status === 404) {
|
||||
console.log('✅ Station deletion verified (404 Not Found)');
|
||||
} else {
|
||||
console.warn('⚠️ Station might still exist');
|
||||
}
|
||||
|
||||
console.log('\n🎉 All tests passed!');
|
||||
return { success: true, message: 'All CRUD operations working correctly' };
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testStationsCRUD();
|
||||
@@ -37,7 +37,6 @@ export default function ConfirmationPage() {
|
||||
try {
|
||||
return await apiClient.get(`/bookings/${bookingId}`);
|
||||
} catch (error) {
|
||||
console.log('Booking API not available, using local data');
|
||||
return {
|
||||
id: bookingId || '',
|
||||
pnr: pnr || undefined,
|
||||
@@ -57,13 +56,9 @@ export default function ConfirmationPage() {
|
||||
// For other payment methods, ticket is generated by the payment webhook after payment completes
|
||||
apiClient.get(`/bookings/${bookingId}`).then((data: any) => {
|
||||
if (data?.status === 'CONFIRMED') {
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
});
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('Failed to fetch booking status:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [bookingId]);
|
||||
|
||||
@@ -142,7 +137,6 @@ export default function ConfirmationPage() {
|
||||
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate voucher:', error);
|
||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsGeneratingVoucher(false);
|
||||
@@ -159,12 +153,12 @@ export default function ConfirmationPage() {
|
||||
|
||||
const handleNewBooking = () => {
|
||||
clearBooking();
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
@@ -376,19 +370,19 @@ export default function ConfirmationPage() {
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Seat(s)</p>
|
||||
{isRoundTrip ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Outbound: {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).outboundCoachNumber})</span>}
|
||||
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Return: {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).inboundCoachNumber})</span>}
|
||||
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{passenger.seatNumber || 'Auto-assigned at boarding'}{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>}
|
||||
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} — {passenger.seatNumber || 'Auto-assigned at boarding'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,9 +34,7 @@ function BookingDetailContent() {
|
||||
queryKey: ['booking-detail', bookingRef],
|
||||
queryFn: async () => {
|
||||
if (!bookingRef) throw new Error('No booking reference provided');
|
||||
console.log('🔍 Fetching Booking:', bookingRef);
|
||||
const response = await apiClient.get(`/bookings/${bookingRef}`);
|
||||
console.log('✅ Booking Response:', response);
|
||||
// Handle wrapped response
|
||||
return (response as any)?.data || response;
|
||||
},
|
||||
@@ -56,7 +54,6 @@ function BookingDetailContent() {
|
||||
return response;
|
||||
},
|
||||
onSuccess: async (data: any) => {
|
||||
console.log('Payment intent created:', data);
|
||||
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
|
||||
paymentIntentId: data.id,
|
||||
paymentMethod: selectedPaymentMethod,
|
||||
@@ -64,7 +61,6 @@ function BookingDetailContent() {
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Payment failed:', error);
|
||||
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
|
||||
},
|
||||
});
|
||||
@@ -99,11 +95,9 @@ function BookingDetailContent() {
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
console.log('📄 Generating voucher for booking:', booking);
|
||||
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
await generateVoucherPDF(booking as any);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate voucher:', error);
|
||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsGeneratingVoucher(false);
|
||||
@@ -148,11 +142,6 @@ function BookingDetailContent() {
|
||||
const isExpired = booking.status === 'EXPIRED';
|
||||
const isCancelled = booking.status === 'CANCELLED';
|
||||
|
||||
console.log('📊 Booking Status:', booking.status);
|
||||
console.log('📊 isPendingPayment:', isPendingPayment);
|
||||
console.log('📊 isConfirmed:', isConfirmed);
|
||||
console.log('📊 isExpired:', isExpired);
|
||||
console.log('📊 isCancelled:', isCancelled);
|
||||
|
||||
const StatusBadge = () => {
|
||||
const statusConfig = {
|
||||
|
||||
@@ -761,7 +761,6 @@ export default function PassengersPage() {
|
||||
// Remove code/state from the URL so a refresh doesn't re-trigger
|
||||
router.replace('/booking/passengers');
|
||||
} catch (error) {
|
||||
console.error('Failed to complete Fayda verification:', error);
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification failed. Please try again or enter details manually.' }));
|
||||
} finally {
|
||||
@@ -793,7 +792,6 @@ export default function PassengersPage() {
|
||||
try {
|
||||
// Fetch passenger profile from backend
|
||||
const passengerData: any = await apiClient.get(`/passengers/me`);
|
||||
console.log('Fetched passenger data:', passengerData);
|
||||
|
||||
if (!passengerData) {
|
||||
setFormInitialized(true);
|
||||
@@ -801,7 +799,6 @@ export default function PassengersPage() {
|
||||
}
|
||||
|
||||
// Only populate first passenger
|
||||
console.log('Setting passenger 0 values');
|
||||
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
|
||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
||||
@@ -818,7 +815,6 @@ export default function PassengersPage() {
|
||||
|
||||
setFormInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger data:', error);
|
||||
setFormInitialized(true);
|
||||
}
|
||||
};
|
||||
@@ -904,7 +900,6 @@ export default function PassengersPage() {
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get verification status:', error);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' }));
|
||||
} finally {
|
||||
@@ -914,7 +909,6 @@ export default function PassengersPage() {
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Fayda verification:', error);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
@@ -943,9 +937,7 @@ export default function PassengersPage() {
|
||||
try {
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id || '';
|
||||
console.log('Fetched passengerId:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,12 +975,10 @@ export default function PassengersPage() {
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('Saved passengerId to booking store:', passengerId);
|
||||
}
|
||||
|
||||
router.push('/booking/seats');
|
||||
} catch (error) {
|
||||
console.error('Failed to save passenger details:', error);
|
||||
alert('Failed to save passenger details. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -997,7 +987,7 @@ export default function PassengersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchCriteria) {
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [searchCriteria, router]);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName } = useBookingStore();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||
@@ -36,6 +36,10 @@ export default function PaymentPage() {
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const isPackage = !!packageTierPriceMinor;
|
||||
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
|
||||
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
|
||||
const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0;
|
||||
|
||||
const displayCurrency = 'ETB' as const;
|
||||
|
||||
@@ -59,29 +63,34 @@ export default function PaymentPage() {
|
||||
queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
|
||||
queryFn: async () => {
|
||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
||||
console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
|
||||
const response: any = await apiClient.get(url);
|
||||
console.log('[BookingAmount] Response:', response);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedMethod && !!bookingId,
|
||||
});
|
||||
|
||||
// Fallback: estimate from local store while API hasn't responded yet.
|
||||
// Uses the same first-child-free calculation as the review page so the
|
||||
// breakdown shown here matches what the passenger already saw there.
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
|
||||
return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
// Per-leg totals across all passengers.
|
||||
// Package: one leg = pkgAdultFare/pkgChildFare (already ×1 per leg; pkgAdultFare already has ×2 for round-trip baked in via pkgRoundTripMultiplier — so per-leg is packageTierPriceMinor).
|
||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
|
||||
const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0;
|
||||
const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0;
|
||||
const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
|
||||
return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
const outboundBaseFare = isPackage
|
||||
? pkgPerLegTotal
|
||||
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0), 0) : 0);
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
||||
}, 0);
|
||||
const inboundBaseFare = isPackage
|
||||
? pkgPerLegTotal
|
||||
: (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 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;
|
||||
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
||||
}, 0);
|
||||
|
||||
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
|
||||
const totalAmount = bookingAmountData != null
|
||||
@@ -115,7 +124,6 @@ export default function PaymentPage() {
|
||||
router.push("/booking/confirmation");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Payment failed:", error);
|
||||
updateStatus("FAILED");
|
||||
setPaymentError(
|
||||
error?.response?.data?.message ||
|
||||
@@ -160,10 +168,6 @@ export default function PaymentPage() {
|
||||
// Add a small delay to allow state to be set from previous page
|
||||
const timer = setTimeout(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
console.log(
|
||||
"Payment page: Missing booking data, redirecting to search",
|
||||
);
|
||||
console.log("bookingId:", bookingId, "pnr:", pnr);
|
||||
router.push("/booking/search");
|
||||
}
|
||||
}, 500);
|
||||
@@ -266,17 +270,22 @@ export default function PaymentPage() {
|
||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
|
||||
{passengers.map((p, i) => {
|
||||
const outFare = outboundSchedule?.baseFareAdult || 0;
|
||||
const inFare = inboundSchedule?.baseFareAdult || 0;
|
||||
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
||||
|
||||
const outboundFare = calculatePassengerFare(passengers, i, outFare);
|
||||
const inboundFare = calculatePassengerFare(passengers, i, inFare);
|
||||
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
|
||||
|
||||
const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
|
||||
const isChildPassenger = isChild(p);
|
||||
const isFreeChild = isChildPassenger && isFirstChild(passengers, i);
|
||||
|
||||
let passengerTotal: number;
|
||||
let isFreeChild = false;
|
||||
if (isPackage) {
|
||||
passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare;
|
||||
} else {
|
||||
const outFare = outboundSchedule?.baseFareAdult || 0;
|
||||
const inFare = inboundSchedule?.baseFareAdult || 0;
|
||||
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
||||
const outboundFare = calculatePassengerFare(passengers, i, outFare);
|
||||
const inboundFare = calculatePassengerFare(passengers, i, inFare);
|
||||
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
|
||||
passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
|
||||
isFreeChild = isChildPassenger && isFirstChild(passengers, i);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
@@ -285,9 +294,9 @@ export default function PaymentPage() {
|
||||
{p.name || `Passenger ${i + 1}`}
|
||||
{isChildPassenger && (
|
||||
<span className={`text-xs font-semibold ml-1 ${
|
||||
isFreeChild ? 'text-green-600' : 'text-blue-600'
|
||||
isPackage ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
|
||||
}`}>
|
||||
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
|
||||
({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -298,12 +307,22 @@ export default function PaymentPage() {
|
||||
{isRoundTrip && (
|
||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(outboundFare, displayCurrency)}</span>
|
||||
<span>Outbound {!isPackage && isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(
|
||||
isPackage
|
||||
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
|
||||
: calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0),
|
||||
displayCurrency
|
||||
)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Return {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(inboundFare, displayCurrency)}</span>
|
||||
<span>Return {!isPackage && isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(
|
||||
isPackage
|
||||
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
|
||||
: calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0),
|
||||
displayCurrency
|
||||
)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -107,11 +107,9 @@ export default function ResultsPage() {
|
||||
payload.returnDate = searchData.returnDate;
|
||||
}
|
||||
|
||||
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await apiClient.post('/search', payload) as any;
|
||||
|
||||
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -15,13 +15,11 @@ import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
try {
|
||||
if (!token) {
|
||||
console.warn('No token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Invalid token format - expected 3 parts, got', parts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,21 +31,16 @@ function getPassengerIdFromToken(token: string): string | null {
|
||||
try {
|
||||
decoded = JSON.parse(atob(padded));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse base64:', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Decoded JWT payload keys:', Object.keys(decoded));
|
||||
console.log('passengerId from JWT:', decoded.passengerId);
|
||||
|
||||
if (!decoded.passengerId) {
|
||||
console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded));
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded.passengerId;
|
||||
} catch (error) {
|
||||
console.error('Error in getPassengerIdFromToken:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +142,6 @@ export default function ReviewPage() {
|
||||
|
||||
setSeatDetails(details);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch seat details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,9 +151,6 @@ export default function ReviewPage() {
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
|
||||
console.log('=== API REQUEST ===');
|
||||
console.log('Endpoint:', endpoint);
|
||||
console.log('Request Data:', JSON.stringify(data, null, 2));
|
||||
return apiClient.post(endpoint, data);
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
@@ -201,9 +190,8 @@ export default function ReviewPage() {
|
||||
}
|
||||
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
alert('Missing search criteria. Please start over.');
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,10 +213,8 @@ export default function ReviewPage() {
|
||||
|
||||
seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id;
|
||||
returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id;
|
||||
console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
if (!seatClassId) {
|
||||
@@ -242,11 +228,9 @@ export default function ReviewPage() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
|
||||
if (!token) {
|
||||
console.error('No token in localStorage');
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
console.log('Token found, length:', token.length);
|
||||
|
||||
let passengerId = getPassengerIdFromToken(token);
|
||||
|
||||
@@ -274,7 +258,6 @@ export default function ReviewPage() {
|
||||
const me: any = await apiClient.get('/passengers/me');
|
||||
passengerId = me?.id || me?.passengerId || '';
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve passengerId from /passengers/me:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,11 +362,8 @@ export default function ReviewPage() {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2));
|
||||
console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -392,30 +372,22 @@ export default function ReviewPage() {
|
||||
if (isRoundTrip) {
|
||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing round trip data');
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing data');
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||
|
||||
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
|
||||
// Package bookings use the stored tier price — no fare calculation needed
|
||||
if (packageTierPriceMinor !== null) return;
|
||||
|
||||
try {
|
||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||
const scheduleSeatClassName = isRoundTrip
|
||||
@@ -445,9 +417,8 @@ export default function ReviewPage() {
|
||||
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
||||
setFareBreakdown(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch fare breakdown:', err);
|
||||
}
|
||||
}, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
|
||||
}, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
||||
@@ -456,7 +427,26 @@ export default function ReviewPage() {
|
||||
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
|
||||
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
|
||||
|
||||
const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0;
|
||||
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPackageBooking = packageTierPriceMinor !== null;
|
||||
// packageTierPriceMinor is the per-adult fare for ONE leg.
|
||||
// Round-trip packages multiply by 2; children pay 10% of the adult fare.
|
||||
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
|
||||
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
|
||||
const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0;
|
||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
|
||||
|
||||
const total = isPackageBooking
|
||||
? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare
|
||||
: (fareBreakdown?.totalMinor ?? 0);
|
||||
|
||||
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
|
||||
const FareSidebar = () => (
|
||||
@@ -466,9 +456,11 @@ export default function ReviewPage() {
|
||||
</h2>
|
||||
{passengers.map((p, i) => {
|
||||
const line = fareBreakdown?.passengers?.[i];
|
||||
const passengerTotal = line?.fareMinor ?? 0;
|
||||
const isChildPassenger = isChild(p);
|
||||
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
||||
const passengerTotal = isPackageBooking
|
||||
? (isChildPassenger ? pkgChildFare : pkgAdultFare)
|
||||
: (line?.fareMinor ?? 0);
|
||||
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)));
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
@@ -477,9 +469,9 @@ export default function ReviewPage() {
|
||||
{p.name || `Passenger ${i + 1}`}
|
||||
{isChildPassenger && (
|
||||
<span className={`text-xs font-semibold ml-1 ${
|
||||
isFreeChild ? 'text-green-600' : 'text-blue-600'
|
||||
isPackageBooking ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
|
||||
}`}>
|
||||
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
|
||||
({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -141,6 +141,9 @@ export default function SeatsPage() {
|
||||
// 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.
|
||||
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 [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<
|
||||
@@ -170,24 +173,17 @@ export default function SeatsPage() {
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType],
|
||||
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId],
|
||||
queryFn: async () => {
|
||||
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`;
|
||||
console.log("🪑 Seatmap Request:", {
|
||||
endpoint,
|
||||
});
|
||||
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);
|
||||
|
||||
console.log("✅ Seatmap Response:", {
|
||||
endpoint,
|
||||
fullResponse: response,
|
||||
dataCoaches: (response as any)?.data?.coaches?.length || 0,
|
||||
rootCoaches: (response as any)?.coaches?.length || 0,
|
||||
});
|
||||
|
||||
const finalData = (response as any)?.data || response;
|
||||
console.log("🎯 Final data structure:", finalData);
|
||||
return finalData;
|
||||
},
|
||||
enabled: !!currentSchedule?.id && !!coachTypeId,
|
||||
@@ -202,15 +198,14 @@ export default function SeatsPage() {
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
|
||||
const isInbound = isRoundTrip && currentJourneyType === "inbound";
|
||||
const isInbound = currentJourneyType === "inbound";
|
||||
|
||||
// Always use the schedule's own station IDs (set from schedule.origin.id / schedule.destination.id)
|
||||
// so they are guaranteed to exist in the trip's TripStopTime records.
|
||||
const scheduleForHold = isInbound ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
||||
const scheduleForHold = isInbound && inboundSchedule ? inboundSchedule : (outboundSchedule || selectedSchedule);
|
||||
const originId = (scheduleForHold as any)?.originStationId || searchCriteria?.originStationId;
|
||||
const destinationId = (scheduleForHold as any)?.destinationStationId || searchCriteria?.destinationStationId;
|
||||
|
||||
console.log('🎫 Hold request:', { scheduleId: currentSchedule?.id, originId, destinationId, isInbound });
|
||||
return apiClient.post(`/seats/hold`, {
|
||||
scheduleId: currentSchedule?.id,
|
||||
originStationId: originId,
|
||||
@@ -220,7 +215,7 @@ export default function SeatsPage() {
|
||||
});
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
const isInbound = isRoundTrip && currentJourneyType === "inbound";
|
||||
const isInbound = currentJourneyType === "inbound";
|
||||
if (isInbound) {
|
||||
// Merge the return hold into the existing outbound hold
|
||||
const current = useBookingStore.getState().seatHold;
|
||||
@@ -258,29 +253,10 @@ export default function SeatsPage() {
|
||||
(seatMapData as any)?.coaches ||
|
||||
(seatMapData as any)?.data?.coaches ||
|
||||
[];
|
||||
console.log("📦 Raw coaches data:", {
|
||||
fromRoot: (seatMapData as any)?.coaches?.length || 0,
|
||||
fromData: (seatMapData as any)?.data?.coaches?.length || 0,
|
||||
using: rawCoaches.length,
|
||||
hasRooms: rawCoaches.some((c: any) => c.rooms?.length > 0),
|
||||
sampleRooms: rawCoaches[0]?.rooms?.length || 0,
|
||||
});
|
||||
return rawCoaches;
|
||||
}, [seatMapData]);
|
||||
|
||||
const filteredCoaches = useMemo(() => {
|
||||
console.log("🔍 Filtering coaches:", {
|
||||
totalCoaches: coaches.length,
|
||||
selectedSeatClass: currentSchedule?.selectedSeatClass,
|
||||
coachesData: coaches.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
label: c.label,
|
||||
seatClass: c.seatClass,
|
||||
seatClasses: c.seatClasses,
|
||||
seatsCount: c.seats?.length || 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const coachesWithSeats = coaches.filter((c: any) => {
|
||||
// Bed coaches store occupants in rooms.beds, not seats
|
||||
@@ -288,12 +264,8 @@ export default function SeatsPage() {
|
||||
return c.seats && c.seats.length > 0;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"✅ Returning all coaches with seats/beds:",
|
||||
coachesWithSeats.length,
|
||||
);
|
||||
return coachesWithSeats;
|
||||
}, [coaches, currentSchedule?.selectedSeatClass]);
|
||||
}, [coaches]);
|
||||
|
||||
|
||||
const selectedCoachData = useMemo(
|
||||
@@ -367,7 +339,7 @@ export default function SeatsPage() {
|
||||
return seats;
|
||||
}, [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(
|
||||
() => new Set(Object.values(passengerSeatMap)),
|
||||
[passengerSeatMap],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
@@ -15,10 +16,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Nonce set per-request by middleware; required for this inline script under the CSP.
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
|
||||
@@ -40,6 +40,11 @@ interface TrainInfo {
|
||||
operatorName: string;
|
||||
}
|
||||
|
||||
interface StopTime {
|
||||
sequence: number;
|
||||
station: Station;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
departureAt: string;
|
||||
@@ -50,6 +55,7 @@ interface Schedule {
|
||||
originStation: Station;
|
||||
destinationStation: Station;
|
||||
train: TrainInfo;
|
||||
stopTimes?: StopTime[];
|
||||
}
|
||||
|
||||
interface PriceTier {
|
||||
@@ -287,6 +293,10 @@ function PriceTiersPanel({
|
||||
|
||||
// ─── Passenger count picker ──────────────────────────────────────────────────
|
||||
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
const PKG_MAX_CHILDREN = 2;
|
||||
const PKG_CHILD_FARE_RATIO = 0.1;
|
||||
|
||||
function PassengerCountModal({
|
||||
tier,
|
||||
onClose,
|
||||
@@ -294,18 +304,23 @@ function PassengerCountModal({
|
||||
loading,
|
||||
error,
|
||||
priceMultiplier,
|
||||
stations,
|
||||
}: {
|
||||
tier: PriceTier;
|
||||
onClose: () => void;
|
||||
onConfirm: (adultCount: number, childCount: number) => void;
|
||||
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
priceMultiplier: number;
|
||||
stations: Station[];
|
||||
}) {
|
||||
const [adultCount, setAdultCount] = useState(1);
|
||||
const [childCount, setChildCount] = useState(0);
|
||||
const total = adultCount + childCount;
|
||||
const [departureStationId, setDepartureStationId] = useState('');
|
||||
const [showStationError, setShowStationError] = useState(false);
|
||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||
const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO);
|
||||
const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -313,7 +328,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="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">
|
||||
<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">
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
@@ -322,13 +337,14 @@ function PassengerCountModal({
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per person</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{[{ label: "Adults", sub: "Age 5+", value: adultCount, min: 1, set: setAdultCount },
|
||||
{ label: "Children", sub: "Under 5", value: childCount, min: 0, set: setChildCount }]
|
||||
.map(({ label, sub, value, min, set }) => (
|
||||
{[
|
||||
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount },
|
||||
{ label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount },
|
||||
].map(({ label, sub, value, min, max, set }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
@@ -342,7 +358,7 @@ function PassengerCountModal({
|
||||
</button>
|
||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
<button type="button" onClick={() => set(value + 1)}
|
||||
disabled={total >= remaining}
|
||||
disabled={value >= max}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||
+
|
||||
</button>
|
||||
@@ -351,8 +367,8 @@ function PassengerCountModal({
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm text-gray-500">Total</span>
|
||||
<span className="text-base font-extrabold text-primary">{formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)}</span>
|
||||
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
|
||||
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -362,8 +378,36 @@ function PassengerCountModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
|
||||
disabled={loading || total < 1}
|
||||
{/* 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); setShowStationError(false); }}
|
||||
className={`w-full rounded-xl border 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 ${
|
||||
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{showStationError && !departureStationId && (
|
||||
<p className="text-xs text-red-500 -mt-2">Please select your boarding station to continue.</p>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
if (!departureStationId) { setShowStationError(true); return; }
|
||||
const station = stations.find(s => s.id === departureStationId);
|
||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||
}}
|
||||
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">
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||
</button>
|
||||
@@ -394,11 +438,16 @@ export default function PackageDetailPage() {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Build departure station list from the outbound schedule's route stops (ordered by sequence)
|
||||
const routeStopStations: Station[] = pkg?.outboundSchedule?.stopTimes?.length
|
||||
? pkg.outboundSchedule.stopTimes.map((st) => st.station).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
||||
|
||||
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;
|
||||
setBookingContextLoading(true);
|
||||
setBookingContextError(null);
|
||||
@@ -460,7 +509,8 @@ export default function PackageDetailPage() {
|
||||
})),
|
||||
);
|
||||
|
||||
setPackageContext(id, selectedTier.id, ctx.totalMinor, pkg.name);
|
||||
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
||||
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName);
|
||||
|
||||
router.push("/booking/passengers");
|
||||
} catch (err: any) {
|
||||
@@ -515,6 +565,7 @@ export default function PackageDetailPage() {
|
||||
loading={bookingContextLoading}
|
||||
error={bookingContextError}
|
||||
priceMultiplier={isRoundTripPkg ? 2 : 1}
|
||||
stations={routeStopStations}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function ProfilePage() {
|
||||
mutationFn: () => apiClient.delete('/auth/account'),
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
@@ -20,7 +20,6 @@ function isStrongPassword(pw: string): boolean {
|
||||
}
|
||||
|
||||
function VerifyAccountContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
|
||||
@@ -65,7 +64,7 @@ function VerifyAccountContent() {
|
||||
});
|
||||
// Auto-login with the freshly-set password; login lazy-provisions the passenger record.
|
||||
await login(email, newPassword);
|
||||
router.push('/booking/search');
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Could not verify your account. Check the code and try again, or resend it.');
|
||||
|
||||
@@ -88,6 +88,8 @@ interface BookingState {
|
||||
packageName: string | null;
|
||||
priceTierId: string | null;
|
||||
packageTierPriceMinor: number | null;
|
||||
packageDepartureStationId: string | null;
|
||||
packageDepartureStationName: string | null;
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
@@ -100,7 +102,7 @@ interface BookingState {
|
||||
setPaymentMethod: (method: string) => void;
|
||||
setCreateAccount: (create: boolean) => 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;
|
||||
}
|
||||
|
||||
@@ -121,9 +123,11 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
packageName: null,
|
||||
priceTierId: null,
|
||||
packageTierPriceMinor: null,
|
||||
packageDepartureStationId: null,
|
||||
packageDepartureStationName: null,
|
||||
|
||||
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 }),
|
||||
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
||||
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
||||
@@ -150,6 +154,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
packageName: null,
|
||||
priceTierId: null,
|
||||
packageTierPriceMinor: null,
|
||||
packageDepartureStationId: null,
|
||||
packageDepartureStationName: null,
|
||||
}),
|
||||
} as BookingState)),
|
||||
{
|
||||
|
||||
119
apps/edr-passenger-web/portal/src/middleware.ts
Normal file
119
apps/edr-passenger-web/portal/src/middleware.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Routes that should NOT redirect to home on hard refresh
|
||||
const PRESERVED_ROUTES = [
|
||||
'/booking/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/set-password',
|
||||
'/verify-account',
|
||||
'/fayda-setup',
|
||||
'/profile',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/help',
|
||||
'/guide',
|
||||
'/services',
|
||||
'/go/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the Content-Security-Policy for a single request.
|
||||
*
|
||||
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
|
||||
* carrying this request's nonce (and scripts they load) may execute, which neutralises
|
||||
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
|
||||
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
|
||||
* request `Content-Security-Policy` header (see below); our own inline scripts read it
|
||||
* from the `x-nonce` request header in the root layout.
|
||||
*
|
||||
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
|
||||
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
|
||||
* plain-HTTP localhost keeps working.
|
||||
*/
|
||||
function buildCsp(nonce: string): string {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
|
||||
let apiOrigin = '';
|
||||
try {
|
||||
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
|
||||
} catch {
|
||||
apiOrigin = '';
|
||||
}
|
||||
|
||||
const scriptSrc = isProd
|
||||
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
|
||||
: `'self' 'unsafe-inline' 'unsafe-eval'`;
|
||||
|
||||
const connectSrc = isProd
|
||||
? `'self' ${apiOrigin}`.trim()
|
||||
: `'self' ${apiOrigin} ws: wss:`.trim();
|
||||
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`base-uri 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
|
||||
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
|
||||
`font-src 'self' data:`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`worker-src 'self' blob:`,
|
||||
`frame-src 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors 'none'`,
|
||||
...(isProd ? ['upgrade-insecure-requests'] : []),
|
||||
];
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = buildCsp(nonce);
|
||||
|
||||
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
|
||||
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
requestHeaders.set('Content-Security-Policy', csp);
|
||||
|
||||
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const isHardRefresh = !request.headers.get('referer');
|
||||
|
||||
let response: NextResponse;
|
||||
if (
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.includes('.') ||
|
||||
pathname === '/'
|
||||
) {
|
||||
// Next.js internals, static files, API routes, and home: no redirect, just render.
|
||||
response = render();
|
||||
} else if (PRESERVED_ROUTES.some((r) => pathname.startsWith(r))) {
|
||||
// On hard refresh of a preserved route, let it through.
|
||||
response = render();
|
||||
} else if (isHardRefresh && pathname.startsWith('/packages')) {
|
||||
// On hard refresh of package detail or packages list, redirect to home.
|
||||
response = NextResponse.redirect(new URL('/', request.url));
|
||||
} else {
|
||||
response = render();
|
||||
}
|
||||
|
||||
response.headers.set('Content-Security-Policy', csp);
|
||||
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
|
||||
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
@@ -28,6 +28,8 @@ export interface Schedule {
|
||||
};
|
||||
originStation?: Station; // For backward compatibility
|
||||
destinationStation?: Station; // For backward compatibility
|
||||
originStationId?: string; // For backward compatibility
|
||||
destinationStationId?: string; // For backward compatibility
|
||||
departureAt?: string; // API returns this
|
||||
arrivalAt?: string; // API returns this
|
||||
departureTime?: string; // For backward compatibility
|
||||
|
||||
Reference in New Issue
Block a user