Merge pull request #551 from Tria-plc/dev

merge
This commit is contained in:
Abubeker Yasin
2026-07-08 22:42:37 +03:00
committed by GitHub
27 changed files with 833 additions and 297 deletions

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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,
})),
],

View File

@@ -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(

View File

@@ -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}`,

View File

@@ -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(),
};

View File

@@ -550,7 +550,6 @@ export class PaymentsService {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
...(region
? {
region: {

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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&apos;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>

View File

@@ -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>
);
}

View File

@@ -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&apos;t received final confirmation from D-Money yet. Taking you to your booking status.
</p>
</>
)}
</div>

View File

@@ -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 (

View File

@@ -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&apos;t received final confirmation from Telebirr yet. Taking you to your booking status.
</p>
</>
)}
</div>

View File

@@ -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&apos;t received final confirmation from Waafi yet. Taking you to your booking status.
</p>
</>
)}
</div>
</div>
);

View File

@@ -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">

View File

@@ -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.');

View File

@@ -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"))

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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