mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
@@ -1424,6 +1424,18 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned handover means the customer must approve delivery.
|
||||
// Surfaced so the portal shows "Approve delivery" as soon as the handover
|
||||
// exists, independent of the truck-arrival flag.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
Boolean(pendingHandover);
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export function ReadonlyBookingView({
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const canApproveDelivery =
|
||||
status === "COMPLETED" ||
|
||||
Boolean(booking.handoverAwaitingSignature) ||
|
||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
||||
const usesCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT"
|
||||
|
||||
@@ -21,6 +21,8 @@ export class PassengerInputDto {
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
||||
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@@ -143,6 +145,9 @@ export class CreateBookingDto {
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@IsOptional() @IsString() priceTierId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -60,6 +60,8 @@ interface BookingFilters {
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
private readonly logger = new Logger(BookingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@@ -546,30 +548,42 @@ export class BookingsService {
|
||||
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = fareCalculation.totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free.
|
||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
||||
// passengersWithFares is built in adult-first order so we track paid children by count.
|
||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
||||
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = fareCalculation.baseFareMinor;
|
||||
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// Use the sum of per-seat fares as the authoritative total when the client supplied
|
||||
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
|
||||
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
|
||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||||
(allFaresProvided
|
||||
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||||
: fareCalculation.totalMinor);
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
let displayTotalMinor = resolvedTotalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -577,7 +591,7 @@ export class BookingsService {
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: fareCalculation.totalMinor,
|
||||
totalMinor: resolvedTotalMinor / 100,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -697,8 +711,8 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free per leg.
|
||||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||
// present (berth-specific pricing). Fall back to fare engine values.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
@@ -706,24 +720,40 @@ export class BookingsService {
|
||||
let returnFareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
||||
const isFreeChild = childIdx < adultCount;
|
||||
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
|
||||
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = returnFare.baseFareMinor;
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
}
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
|
||||
// supplied their fares — free children (no seatId) are excluded from the check.
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor) {
|
||||
totalMinor = dto.reviewedTotalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
} else {
|
||||
displayTotalMinor = totalMinor;
|
||||
}
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
|
||||
@IsOptional() @IsString() email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' })
|
||||
@IsOptional() @IsInt() seatFareMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' })
|
||||
@IsOptional() @IsInt() returnSeatFareMinor?: number;
|
||||
}
|
||||
|
||||
export class CreateGuestBookingDto {
|
||||
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@IsOptional() @IsString() priceTierId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -185,12 +185,38 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
|
||||
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
|
||||
// Free children (first child, non-package) get fareMinor=0.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = p.seatFareMinor ?? baseFareMinor;
|
||||
} else if (isPackageOneway) {
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = p.seatFareMinor ?? childUnitFare;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// Use reviewedTotalMinor from frontend as authoritative total when provided.
|
||||
// Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||||
(allFaresProvided
|
||||
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||||
: Math.max(0, totalBaseFareMinor - discountMinor));
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
let displayTotalMinor = resolvedTotalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Resolve or create the guest Passenger record
|
||||
@@ -224,7 +250,7 @@ export class GuestBookingService {
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
totalMinor: resolvedTotalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -235,7 +261,7 @@ export class GuestBookingService {
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -245,7 +271,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||
fareMinor: p.fareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
@@ -278,7 +304,7 @@ export class GuestBookingService {
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
@@ -423,13 +449,51 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
|
||||
} else if (isPackageRoundTrip) {
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
|
||||
}
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
// Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares
|
||||
// when all seated passengers supplied their fares.
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor) {
|
||||
totalMinor = dto.reviewedTotalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
}
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
@@ -461,7 +525,7 @@ export class GuestBookingService {
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map((p) => ({
|
||||
...passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -473,10 +537,10 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||
fareMinor: p.outboundFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
...passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
@@ -488,7 +552,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||
fareMinor: p.returnFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -30,23 +30,59 @@ export class CurrencyService {
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Converts a stored display-currency minor amount to the charge major amount
|
||||
* sent to the payment provider, without hitting the DB for an exchange rate.
|
||||
* Use this when the payment method's settlement currency matches the booking's
|
||||
* displayCurrency — the rate is already baked into displayTotalMinor.
|
||||
*/
|
||||
displayMinorToChargeMajor(displayMinor: number, currency: string): number {
|
||||
const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()];
|
||||
if (decimals === undefined) {
|
||||
throw new BadRequestException(`Unsupported charge currency: ${currency}`);
|
||||
}
|
||||
return this.roundTo(displayMinor / 100, decimals);
|
||||
}
|
||||
|
||||
async convertEtbMinorToChargeMinor(
|
||||
amountMinorEtb: number,
|
||||
targetCurrency: string,
|
||||
): Promise<number> {
|
||||
const target = targetCurrency.toUpperCase();
|
||||
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||||
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||
}
|
||||
|
||||
if (target === Currency.ETB) {
|
||||
return amountMinorEtb;
|
||||
}
|
||||
|
||||
// Convert ETB minor → target minor: apply exchange rate, keep as minor units.
|
||||
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||
return Math.round(amountMinorEtb * rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ETB minor-unit amount to the charge major-unit amount sent to the
|
||||
* payment provider. Applies the exchange rate for foreign currencies then divides
|
||||
* by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
|
||||
*/
|
||||
async convertEtbMinorToChargeMajor(
|
||||
amountMinorEtb: number,
|
||||
targetCurrency: string,
|
||||
): Promise<number> {
|
||||
const target = targetCurrency.toUpperCase();
|
||||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||
if (decimals === undefined) {
|
||||
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||||
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||
}
|
||||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||
|
||||
const sourceMajor = amountMinorEtb / 100;
|
||||
if (target === Currency.ETB) {
|
||||
return this.roundTo(sourceMajor, decimals);
|
||||
return this.roundTo(amountMinorEtb / 100, decimals);
|
||||
}
|
||||
|
||||
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||
return this.roundTo(sourceMajor * rate, decimals);
|
||||
return this.roundTo((amountMinorEtb * rate) / 100, decimals);
|
||||
}
|
||||
|
||||
async getRateOrThrow(
|
||||
|
||||
@@ -105,10 +105,16 @@ export class FareEngineService {
|
||||
|
||||
if (segmentOverride) {
|
||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
|
||||
baseFarePerPassengerMinor *= 2;
|
||||
}
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SEGMENT_FARE_RULE';
|
||||
} else if (fareRule?.tripId) {
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
|
||||
baseFarePerPassengerMinor *= 2;
|
||||
}
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SCHEDULE_FARE_RULE';
|
||||
} else {
|
||||
@@ -165,7 +171,7 @@ export class FareEngineService {
|
||||
|
||||
const calculation = [
|
||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`,
|
||||
`Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
|
||||
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
|
||||
`USD→ETB rate: ${usdToEtbRate}`,
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("PaymentsService", () => {
|
||||
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||
const mockCurrencyService = {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor / 100),
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -550,7 +550,6 @@ export class PaymentsService {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
...(region
|
||||
? {
|
||||
region: {
|
||||
|
||||
@@ -209,7 +209,8 @@ export class SearchService {
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
|
||||
const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime()));
|
||||
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
|
||||
const earliest = isToday ? cutoffThreshold : date;
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator';
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
coachTypeId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy Seat' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard economy seating' })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: 45000, description: 'Base price in minor currency units' })
|
||||
@ApiPropertyOptional({ enum: ['LOCAL', 'INTERNATIONAL'] })
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'INTERNATIONAL'])
|
||||
nationalityType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['UPPER', 'MIDDLE', 'LOWER'] })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bedPosition?: string;
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||
@IsInt()
|
||||
basePrice: number;
|
||||
|
||||
|
||||
@@ -326,11 +326,6 @@ export default function ClassesPage() {
|
||||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
||||
<p>Total = (Base Fare × Distance) + Insurance</p>
|
||||
<p className="mt-2 text-xs">• Insurance applies per passenger</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
|
||||
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
|
||||
const COACH_TYPE_LABELS: Record<string, string> = {
|
||||
HSC: 'Regular Seat (Hard Seat)',
|
||||
@@ -18,7 +17,6 @@ const COACH_TYPE_LABELS: Record<string, string> = {
|
||||
SBC: 'VIP Bed (Soft Berth)',
|
||||
};
|
||||
|
||||
// Tariff reference rates per the official policy document
|
||||
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
|
||||
LOCAL: {
|
||||
'HSC-null': 0.03,
|
||||
@@ -109,7 +107,7 @@ export default function TariffRatesPage() {
|
||||
name: fd.get('name') as string,
|
||||
nationalityType: selectedNationalityType,
|
||||
bedPosition: selectedBedPosition || null,
|
||||
baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
|
||||
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
||||
isActive: fd.get('isActive') === 'true',
|
||||
};
|
||||
if (editingClass) {
|
||||
@@ -127,7 +125,6 @@ export default function TariffRatesPage() {
|
||||
? classesData
|
||||
: (classesData as any)?.items || (classesData as any)?.data || [];
|
||||
|
||||
// Only show classes that have nationalityType set (tariff-managed rows)
|
||||
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
|
||||
|
||||
const displayed = tariffClasses.filter((c: any) => {
|
||||
@@ -141,7 +138,6 @@ export default function TariffRatesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-suggest name from selections
|
||||
const suggestName = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
@@ -151,13 +147,12 @@ export default function TariffRatesPage() {
|
||||
return `${label}${pos} (${nat})`;
|
||||
};
|
||||
|
||||
// Auto-suggest baseFareMinor from tariff reference
|
||||
// Returns the human-readable rate (e.g. 0.03); stored value = this × 100
|
||||
const suggestRate = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
||||
// baseFareMinor = tariff_decimal × 100000
|
||||
return ref ? Math.round(ref * 100000).toString() : '';
|
||||
return ref ? String(ref) : '';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -184,18 +179,18 @@ export default function TariffRatesPage() {
|
||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor', label: 'Rate per km (minor)',
|
||||
key: 'baseFareMinor', label: 'Rate per km',
|
||||
render: (c: any) => {
|
||||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
|
||||
const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
|
||||
const tariffMinor = ref ? Math.round(ref * 100) : undefined;
|
||||
const matches = tariffMinor === c.baseFareMinor;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-medium">{c.baseFareMinor}</span>
|
||||
<span className="font-mono font-medium">{c.baseFareMinor / 100}</span>
|
||||
{tariffMinor !== undefined && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
|
||||
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
|
||||
{matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +232,6 @@ export default function TariffRatesPage() {
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="card">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -367,15 +361,16 @@ export default function TariffRatesPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare Minor (per km) *</label>
|
||||
<label className="label">Rate per km *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass?.baseFareMinor ?? ''}
|
||||
defaultValue={editingClass ? editingClass.baseFareMinor / 100 : ''}
|
||||
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||
placeholder={suggestRate() || 'e.g. 3000'}
|
||||
placeholder={suggestRate() || 'e.g. 6'}
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
/>
|
||||
{suggestRate() && (
|
||||
@@ -391,7 +386,7 @@ export default function TariffRatesPage() {
|
||||
>
|
||||
{suggestRate()}
|
||||
</button>
|
||||
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
|
||||
{' '}(stored as {Math.round(Number(suggestRate()) * 100)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
|
||||
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ConfirmationPage() {
|
||||
return {
|
||||
id: bookingId || '',
|
||||
pnr: pnr || undefined,
|
||||
status: 'CONFIRMED',
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,10 @@ export default function ConfirmationPage() {
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
|
||||
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
|
||||
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
|
||||
const isConfirmed = _booking?.status === 'CONFIRMED';
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
@@ -190,14 +194,33 @@ export default function ConfirmationPage() {
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
{isConfirmed ? (
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-12 h-12 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
{isConfirmed ? (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold text-amber-600 dark:text-amber-400 mb-2">
|
||||
Booking received — payment pending
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
We haven't confirmed your payment yet. Your tickets will be issued once payment is completed.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PNR Card */}
|
||||
@@ -340,7 +363,9 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
|
||||
<p className="font-semibold text-green-600 dark:text-green-400">{_booking?.status || 'CONFIRMED'}</p>
|
||||
<p className={`font-semibold ${isConfirmed ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{_booking?.status || 'PENDING_PAYMENT'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
|
||||
@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
|
||||
<div className="space-y-4">
|
||||
{passengers.map((passenger, index) => {
|
||||
const backendTicket = _booking?.ticket || null;
|
||||
const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||
const ticketNumber = isConfirmed
|
||||
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
||||
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
||||
</div>
|
||||
<span className="badge badge-success">CONFIRMED</span>
|
||||
{isConfirmed ? (
|
||||
<span className="badge badge-success">CONFIRMED</span>
|
||||
) : (
|
||||
<span className="badge badge-warning">AWAITING PAYMENT</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function DmoneyFailureContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
// A Manage Booking payment (paying for an already-existing booking) has no in-progress
|
||||
// booking-store session to go "back to review" from — send it back to that booking's
|
||||
// detail view instead, where the user can pick a different payment method.
|
||||
const [backTarget, setBackTarget] = useState('/booking/review');
|
||||
|
||||
// D-Money callback query params (mirrors Telebirr)
|
||||
const merchantOrderId = searchParams.get('merchantOrderId') || '';
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
const resultCode = searchParams.get('resultCode') || searchParams.get('code') || '';
|
||||
const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.';
|
||||
|
||||
useEffect(() => {
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
if (manageBookingRef) {
|
||||
setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
|
||||
}
|
||||
updateStatus('FAILED');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{resultMsg}</p>
|
||||
{resultCode && <p className="text-xs text-gray-400 mb-1">Code: {resultCode}</p>}
|
||||
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
|
||||
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button onClick={() => router.push(backTarget)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DmoneyFailurePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
|
||||
<DmoneyFailureContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not
|
||||
// proof of payment. Poll the backend (which reconciles with the provider) before showing
|
||||
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
|
||||
// what the browser shows.
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function DmoneySuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// D-Money callback query params (mirrors Telebirr)
|
||||
@@ -19,30 +46,56 @@ function DmoneySuccessContent() {
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
setReturnTarget(target);
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your D-Money payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -52,15 +105,25 @@ function DmoneySuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
|
||||
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your D-Money payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-primary w-full">Continue</button>
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from D-Money yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -436,7 +436,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => {
|
||||
{paymentMethods.filter(m => m.enabled).map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
|
||||
@@ -3,15 +3,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not
|
||||
// proof of payment. Poll the backend (which reconciles with the provider) before showing
|
||||
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
|
||||
// what the browser shows.
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function TelebirrSuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// Telebirr callback query params
|
||||
@@ -19,30 +46,56 @@ function TelebirrSuccessContent() {
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
setReturnTarget(target);
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Telebirr payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
|
||||
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Telebirr payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-primary w-full">Continue</button>
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from Telebirr yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,41 @@
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on
|
||||
// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which
|
||||
// reconciles with the provider) before showing "Payment Successful".
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function WaafiSuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// Waafi callback query params
|
||||
const referenceId = searchParams.get('referenceId') || '';
|
||||
@@ -19,29 +46,56 @@ function WaafiSuccessContent() {
|
||||
const currency = searchParams.get('currency') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
setReturnTarget(target);
|
||||
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Waafi payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -54,6 +108,27 @@ function WaafiSuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Waafi payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from Waafi yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -379,36 +379,50 @@ export default function ResultsPage() {
|
||||
const coachCurrency = "ETB";
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
const selectThisCoach = () =>
|
||||
handleSelectCoachType(
|
||||
scheduleId,
|
||||
coachType.coachTypeId,
|
||||
coachType.coachTypeCode,
|
||||
coachType.coachTypeName,
|
||||
coachType.classes?.[0]?.name ||
|
||||
coachType.coachTypeName,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={coachType.coachId}
|
||||
onClick={() =>
|
||||
handleSelectCoachType(
|
||||
scheduleId,
|
||||
coachType.coachTypeId,
|
||||
coachType.coachTypeCode,
|
||||
coachType.coachTypeName,
|
||||
coachType.classes?.[0]?.name ||
|
||||
coachType.coachTypeName,
|
||||
)
|
||||
}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectThisCoach}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectThisCoach();
|
||||
}
|
||||
}}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
|
||||
: "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
|
||||
}`}
|
||||
style={{
|
||||
animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both`,
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||
<Check
|
||||
className="w-4 h-4 text-white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Radio indicator — top-right, persistent (not hover-only) so the
|
||||
card's selection state is clear on touch too. */}
|
||||
<span
|
||||
className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected
|
||||
? "border-primary"
|
||||
: "border-gray-300 dark:border-gray-600 group-hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-primary animate-scale-in" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4 pr-2">
|
||||
@@ -474,7 +488,7 @@ export default function ResultsPage() {
|
||||
(cls: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
||||
@@ -496,8 +510,37 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note — only shown while unselected; once picked, the Continue
|
||||
button below takes its place. */}
|
||||
{!isSelected && (
|
||||
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
|
||||
Click to select this coach
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Continue only appears on the card the user has actually picked —
|
||||
a real nested button (the outer card is a div, not a button, so
|
||||
this doesn't create invalid/ambiguous nested-button behavior). */}
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
? "Continue to Return Journey"
|
||||
: "Continue to Passenger Details"}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -512,33 +555,6 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCoachType) {
|
||||
handleSelect(classModal, isOutbound);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedCoachType}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
? "Continue to Return Journey"
|
||||
: "Continue to Passenger Details"}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedCoachType && (
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
|
||||
Select a coach type to continue
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||
@@ -935,7 +951,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -970,7 +986,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="card text-center mb-8 max-w-3xl mx-auto">
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -1019,7 +1035,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -1157,7 +1173,7 @@ export default function ResultsPage() {
|
||||
alternativeOutbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
@@ -1257,7 +1273,7 @@ export default function ResultsPage() {
|
||||
alternativeInbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
|
||||
@@ -343,6 +343,9 @@ export default function ReviewPage() {
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
...(isRoundTrip
|
||||
? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
|
||||
: { seatFareMinor: p.seatFareMinor ?? undefined }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -397,8 +400,9 @@ export default function ReviewPage() {
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
...(isRoundTrip
|
||||
? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
|
||||
: { seatFareMinor: p.seatFareMinor ?? undefined }),
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
@@ -444,6 +448,11 @@ export default function ReviewPage() {
|
||||
return { fareMinor, isFree: isFreeChild };
|
||||
});
|
||||
setReviewedTotal(computedTotal, passengerFares);
|
||||
|
||||
// Pass the exact total shown on this page to the backend so it stores the
|
||||
// correct berth-specific amount regardless of what the fare engine calculates.
|
||||
bookingData.reviewedTotalMinor = computedTotal;
|
||||
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
|
||||
@@ -429,7 +429,7 @@ function StationDropdown({
|
||||
(s.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
s.code?.toLowerCase().includes(query.toLowerCase())),
|
||||
)
|
||||
: stations.filter((s) => s.id !== excludeId).slice(0, 8);
|
||||
: stations.filter((s) => s.id !== excludeId).slice(0, 20);
|
||||
|
||||
const displayValue = open ? query : (selectedStation?.name ?? "");
|
||||
|
||||
@@ -475,7 +475,7 @@ function StationDropdown({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-56 overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-96 overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
{!query && recentIds.length > 0 && (
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
@@ -1161,8 +1161,6 @@ export default function SearchPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
@@ -1192,8 +1190,6 @@ export default function SearchPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Pax + Nationality */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
@@ -1202,11 +1198,11 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPassengerModalOpen(true)}
|
||||
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white hover:border-gray-300 transition-all ${
|
||||
showNationalityError ? "border-red-400" : "border-gray-200"
|
||||
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
|
||||
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax
|
||||
{nationalityFlag(watch("nationality"))
|
||||
|
||||
@@ -315,6 +315,11 @@ export default function SeatsPage() {
|
||||
// first real pick (no fare-change modal) even though the map already has an entry for
|
||||
// them — only a click AFTER that (replacing their own real pick) is an actual change.
|
||||
const restoredIndicesRef = useRef<Set<number>>(new Set());
|
||||
// Set by handleAutoAssign right before it updates passengerSeatMap, so the effect below
|
||||
// can proceed straight to Continue once that state update actually lands — calling
|
||||
// handleContinue() synchronously in the same tick would still see the pre-update
|
||||
// passengerSeatMap/allSeatsAssigned from this render's closure.
|
||||
const autoContinueAfterAssignRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
|
||||
if (restoredLegRef.current === legKey) return;
|
||||
@@ -689,10 +694,6 @@ export default function SeatsPage() {
|
||||
() => filteredCoaches.find((c: any) => c.id === selectedCoach),
|
||||
[filteredCoaches, selectedCoach],
|
||||
);
|
||||
const allSeats = useMemo(
|
||||
() => selectedCoachData?.seats || [],
|
||||
[selectedCoachData],
|
||||
);
|
||||
|
||||
const getBedPosition = (selectedClass: string): string | null => {
|
||||
const lowerClass = selectedClass.toLowerCase();
|
||||
@@ -702,59 +703,87 @@ export default function SeatsPage() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const validSeats = useMemo(() => {
|
||||
// If coach has rooms, extract all beds from rooms
|
||||
if (selectedCoachData?.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
selectedCoachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) {
|
||||
allBeds.push(...room.beds);
|
||||
}
|
||||
});
|
||||
// Extracted so it can be applied to ANY coach, not just the one currently expanded —
|
||||
// Auto Assign needs to look across every coach of this type, not just selectedCoachData.
|
||||
const getValidSeatsForCoach = useCallback(
|
||||
(coachData: any): any[] => {
|
||||
if (!coachData) return [];
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
// If coach has rooms, extract all beds from rooms
|
||||
if (coachData.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
coachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) {
|
||||
allBeds.push(...room.beds);
|
||||
}
|
||||
});
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
const isBedCoach =
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return beds;
|
||||
}
|
||||
|
||||
// Fallback to old seat structure
|
||||
let seats = (coachData.seats || []).filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
const isBedCoach =
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
coachData.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return beds;
|
||||
}
|
||||
return seats;
|
||||
},
|
||||
[currentSchedule?.selectedSeatClass],
|
||||
);
|
||||
|
||||
// Fallback to old seat structure
|
||||
let seats = allSeats.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
const isBedCoach =
|
||||
selectedCoachData?.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
const validSeats = useMemo(
|
||||
() => getValidSeatsForCoach(selectedCoachData),
|
||||
[getValidSeatsForCoach, selectedCoachData],
|
||||
);
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return seats;
|
||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||
// Every valid seat across every coach of the current type, tagged with which coach it
|
||||
// belongs to — lets Auto Assign search the whole train (not just the one expanded coach)
|
||||
// and lets handleContinue resolve a passenger's coach label even when their seat came
|
||||
// from a coach they never manually expanded.
|
||||
const allCoachSeats = useMemo(
|
||||
() =>
|
||||
filteredCoaches.flatMap((c: any) =>
|
||||
getValidSeatsForCoach(c).map((s: any) => ({
|
||||
...s,
|
||||
_coachId: c.id,
|
||||
_coachLabel: c.label || c.name || c.coachNumber || c.number || "",
|
||||
})),
|
||||
),
|
||||
[filteredCoaches, getValidSeatsForCoach],
|
||||
);
|
||||
|
||||
// Seats already claimed by any passenger in this journey leg, plus outbound
|
||||
const assignedSeatIds = useMemo(
|
||||
@@ -989,12 +1018,15 @@ export default function SeatsPage() {
|
||||
try {
|
||||
await ensureLegHold(seatIdsForHold);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
// Look up from allCoachSeats (every coach of this type), not just the currently
|
||||
// expanded one — Auto Assign can place passengers in a coach that was never
|
||||
// manually expanded.
|
||||
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
return {
|
||||
...p,
|
||||
outboundSeatId: seatIds[i],
|
||||
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
outboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
outboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
outboundBedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1170,13 +1202,16 @@ export default function SeatsPage() {
|
||||
try {
|
||||
await ensureLegHold(seatIdsForHold);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
// See the outbound branch above — resolve from allCoachSeats so a passenger whose
|
||||
// seat came from a coach Auto Assign picked (but was never manually expanded)
|
||||
// still gets the right coach label.
|
||||
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
if (isRoundTrip && currentJourneyType === "inbound") {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: seatIds[i],
|
||||
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
inboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
inboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
inboundBedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1185,7 +1220,7 @@ export default function SeatsPage() {
|
||||
...p,
|
||||
seatId: seatIds[i],
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
coachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
seatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
bedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1197,7 +1232,7 @@ export default function SeatsPage() {
|
||||
if (!isRoundTrip && isPackageBooking && packageId) {
|
||||
const firstEligibleIdx = seatEligibleIndices[0];
|
||||
const firstSeatData = firstEligibleIdx != null
|
||||
? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
|
||||
? allCoachSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
|
||||
: null;
|
||||
const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null;
|
||||
if (berthFare != null) {
|
||||
@@ -1228,20 +1263,38 @@ export default function SeatsPage() {
|
||||
router.push("/booking/review");
|
||||
};
|
||||
|
||||
// Fires once the passengerSeatMap update from handleAutoAssign has actually landed (and
|
||||
// every eligible passenger now has a seat) — proceeds straight to Continue so the user
|
||||
// doesn't have to click it separately after auto-assigning.
|
||||
useEffect(() => {
|
||||
if (autoContinueAfterAssignRef.current && allSeatsAssigned) {
|
||||
autoContinueAfterAssignRef.current = false;
|
||||
handleContinue();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passengerSeatMap, allSeatsAssigned]);
|
||||
|
||||
const handleAutoAssign = () => {
|
||||
const unassignedIndices = seatEligibleIndices.filter((i) => !passengerSeatMap[i]);
|
||||
|
||||
if (unassignedIndices.length === 0) return;
|
||||
|
||||
const availableSeats = (
|
||||
validSeats?.filter((s: any) => s.status === "AVAILABLE") || []
|
||||
).filter((s: any) => !assignedSeatIds.has(s.id));
|
||||
// Search the whole train (every coach of this type), not just whichever one happens to
|
||||
// be expanded — fill the currently-expanded coach first so the result stays visible
|
||||
// without switching coaches, then spill over into other coaches only if needed.
|
||||
const availableAcrossTrain = allCoachSeats
|
||||
.filter((s: any) => s.status === "AVAILABLE" && !assignedSeatIds.has(s.id))
|
||||
.sort((a: any, b: any) => {
|
||||
const aCurrent = a._coachId === selectedCoach ? 0 : 1;
|
||||
const bCurrent = b._coachId === selectedCoach ? 0 : 1;
|
||||
return aCurrent - bCurrent;
|
||||
});
|
||||
|
||||
if (availableSeats.length < unassignedIndices.length) {
|
||||
if (availableAcrossTrain.length < unassignedIndices.length) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Not Enough Seats",
|
||||
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
|
||||
message: `Only ${availableAcrossTrain.length} seat(s) available across this coach type, but you need ${unassignedIndices.length} more seat(s). Please select another coach type.`,
|
||||
type: "warning",
|
||||
onConfirm: undefined,
|
||||
showCancel: false,
|
||||
@@ -1251,11 +1304,21 @@ export default function SeatsPage() {
|
||||
}
|
||||
|
||||
const next = { ...passengerSeatMap };
|
||||
let lastAssignedCoachId: string | null = null;
|
||||
unassignedIndices.forEach((passengerIndex, offset) => {
|
||||
next[passengerIndex] = availableSeats[offset].id;
|
||||
const seat = availableAcrossTrain[offset];
|
||||
next[passengerIndex] = seat.id;
|
||||
lastAssignedCoachId = seat._coachId;
|
||||
});
|
||||
// Proceed straight to Continue once this update lands — no separate click needed.
|
||||
autoContinueAfterAssignRef.current = true;
|
||||
setPassengerSeatMap(next);
|
||||
setActivePassengerIndex(seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0);
|
||||
// Bring whichever coach the last passenger landed in into view, since seats may now
|
||||
// span coaches beyond the one that was expanded when Auto Assign was clicked.
|
||||
if (lastAssignedCoachId && lastAssignedCoachId !== selectedCoach) {
|
||||
setSelectedCoach(lastAssignedCoachId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPassengers = () => {
|
||||
@@ -1857,8 +1920,10 @@ export default function SeatsPage() {
|
||||
}
|
||||
|
||||
const assignedSeatId = passengerSeatMap[i];
|
||||
// allCoachSeats (not validSeats) so a seat Auto Assign placed in a coach the user
|
||||
// never manually expanded still shows correctly here.
|
||||
const assignedSeat = assignedSeatId
|
||||
? validSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
|
||||
const seatFare = assignedSeat
|
||||
|
||||
@@ -10,20 +10,6 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
function useDarkMode() {
|
||||
const [dark, setDark] = useState(() =>
|
||||
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() =>
|
||||
setDark(document.documentElement.classList.contains('dark'))
|
||||
);
|
||||
obs.observe(document.documentElement, { attributeFilter: ['class'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
return dark;
|
||||
}
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
@@ -86,44 +72,42 @@ function CustomSelect({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const dark = useDarkMode();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((o) => !o)}
|
||||
className={`w-full ${icon ? 'pl-11' : 'pl-4'} pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2
|
||||
bg-white dark:bg-gray-800
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
transition-colors`}
|
||||
style={{ color: dark ? '#ffffff' : '#111827' }}
|
||||
className={[
|
||||
'w-full pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2 transition-colors',
|
||||
'bg-white dark:bg-gray-800 text-gray-900 dark:text-white',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
icon ? 'pl-11' : 'pl-4',
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2">{icon}</span>}
|
||||
<span style={{ color: selected ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}>
|
||||
<span className={selected ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto text-gray-900 dark:text-white">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
className={`w-full text-left px-4 py-3 text-sm transition-colors
|
||||
${opt.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer'}
|
||||
${opt.value === value
|
||||
? 'text-primary font-semibold bg-primary/5 dark:bg-primary/10'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
className={[
|
||||
'w-full text-left px-4 py-3 text-sm transition-colors',
|
||||
opt.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer',
|
||||
opt.value === value ? 'text-primary font-semibold bg-primary/5 dark:bg-primary/10' : 'text-gray-900 dark:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -139,7 +123,6 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
const router = useRouter();
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||
const dark = useDarkMode();
|
||||
|
||||
const { data: stations, isLoading } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
@@ -255,10 +238,9 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
||||
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 flex items-center justify-between hover:border-primary transition-colors"
|
||||
style={{ color: dark ? '#ffffff' : '#111827' }}
|
||||
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white flex items-center justify-between hover:border-primary transition-colors dark:[color-scheme:dark]"
|
||||
>
|
||||
<span className="flex items-center gap-2" style={{ color: 'inherit' }}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
@@ -268,7 +250,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
{isPassengerOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 p-4 space-y-4">
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 p-4 space-y-4 text-gray-900 dark:text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">Adults</div>
|
||||
|
||||
@@ -52,11 +52,14 @@ const PAGE_MARGIN = 18;
|
||||
// Encodes everything a gate scanner needs to verify this specific ticket without
|
||||
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
|
||||
// departure time and fare. Kept as compact JSON so any generic QR reader can parse it.
|
||||
// Field names (`ref`/`ticketNumber`) must match what the backoffice boarding scanner and
|
||||
// tickets.service.ts's scanAndBoard() read from the QR payload — see apps/edr-passenger-api/
|
||||
// src/modules/tickets/tickets.service.ts.
|
||||
function buildTicketQrPayload(data: PassengerVoucherData): string {
|
||||
return JSON.stringify({
|
||||
type: 'EDR_TICKET',
|
||||
pnr: data.bookingRef,
|
||||
ticket: data.ticketNumber,
|
||||
ref: data.bookingRef,
|
||||
ticketNumber: data.ticketNumber,
|
||||
passenger: data.passengerName,
|
||||
status: data.status,
|
||||
train: data.outboundSchedule.trainNumber,
|
||||
@@ -165,6 +168,10 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
const qrSize = 22;
|
||||
const qrPad = 2.5;
|
||||
const cardSize = qrSize + qrPad * 2;
|
||||
const qrGap = 3;
|
||||
// The QR box is anchored to the right edge of the card — reserve that space so the
|
||||
// status pill (also right-anchored) never draws underneath/over it.
|
||||
const qrCardX = pageWidth - margin - cardSize - qrGap;
|
||||
|
||||
doc.setFillColor(...SURFACE);
|
||||
doc.setDrawColor(...HAIRLINE);
|
||||
@@ -172,7 +179,8 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
|
||||
|
||||
const padX = 7;
|
||||
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
|
||||
const pillRightX = qrDataUrl ? qrCardX - qrGap : pageWidth - margin - padX;
|
||||
drawStatusPill(doc, status, pillRightX, y + 5.5, 'right');
|
||||
|
||||
label(doc, 'Booking reference', margin + padX, y + 12);
|
||||
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
|
||||
@@ -183,13 +191,12 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
|
||||
|
||||
if (qrDataUrl) {
|
||||
const cardX = pageWidth - margin - cardSize - 3;
|
||||
const cardY = y + (cardH - cardSize) / 2;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...HAIRLINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||
doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||
doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||
}
|
||||
|
||||
return y + cardH + 10;
|
||||
|
||||
@@ -521,6 +521,8 @@ export interface IBooking extends BaseEntity {
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
customerTruckArrivedAt?: string | null;
|
||||
/** A handover has been generated for this booking and is awaiting the customer's signature. */
|
||||
handoverAwaitingSignature?: boolean;
|
||||
|
||||
customsClearingEnabled?: boolean;
|
||||
// (multi-truck self-haul lives in ICustomerTruck[], fetched via the
|
||||
|
||||
Reference in New Issue
Block a user