mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -14,8 +14,12 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'BOTH'
|
||||
WHERE trade_direction::text = 'ANY';
|
||||
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'IMPORT'
|
||||
WHERE trade_direction IS NULL;
|
||||
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
|
||||
END $$;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -494,38 +494,44 @@ model FareRule {
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(uuid())
|
||||
bookingRef String @unique
|
||||
passengerId String
|
||||
scheduleId String
|
||||
status BookingStatus @default(DRAFT)
|
||||
currency String @default("ETB")
|
||||
totalMinor Int
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
displayCurrency Currency?
|
||||
displayTotalMinor Int?
|
||||
bookingType String @default("ONE_WAY")
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
source String @default("WEB")
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
id String @id @default(uuid())
|
||||
bookingRef String @unique
|
||||
passengerId String
|
||||
scheduleId String
|
||||
bookingType String @default("ONE_WAY")
|
||||
status BookingStatus @default(DRAFT)
|
||||
currency String @default("ETB")
|
||||
totalMinor Int
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
displayCurrency Currency?
|
||||
displayTotalMinor Int?
|
||||
returnScheduleId String?
|
||||
returnOriginStationId String?
|
||||
returnDestinationStationId String?
|
||||
returnHoldId String?
|
||||
returnSeatClassId String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
source String @default("WEB")
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
|
||||
@@index([passengerId, status])
|
||||
@@index([bookingType])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -144,9 +144,20 @@ export class BookingsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create booking (requires login)',
|
||||
description: `Creates a booking for logged-in users with saved passenger profiles.
|
||||
Use POST /bookings/guest for guest checkout without login.`
|
||||
summary: 'Create booking (one-way or round-trip)',
|
||||
description: `Creates a one-way or round-trip booking for logged-in users.
|
||||
|
||||
ONE_WAY booking:
|
||||
- scheduleId, holdId, originStationId, destinationStationId
|
||||
- passengers: array of PassengerInputDto with seatId
|
||||
- Single PNR, single payment
|
||||
|
||||
ROUND_TRIP booking:
|
||||
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
|
||||
- Combined PNR, single payment for both legs
|
||||
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
||||
|
||||
@@ -15,53 +15,130 @@ export class PassengerInputDto {
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
|
||||
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
|
||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
|
||||
@ApiProperty({
|
||||
description: 'Outbound journey seat ID',
|
||||
example: 'seat-uuid-outbound'
|
||||
})
|
||||
@IsString() outboundSeatId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Return journey seat ID',
|
||||
example: 'seat-uuid-return'
|
||||
})
|
||||
@IsString() returnSeatId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Abebe Kebede',
|
||||
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
|
||||
})
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '1990-05-15',
|
||||
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
|
||||
})
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'NATIONAL_ID',
|
||||
enum: IdDocumentType,
|
||||
description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others'
|
||||
})
|
||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'ET123456789',
|
||||
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
|
||||
})
|
||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@ApiProperty() @IsString() passengerId: string;
|
||||
@ApiProperty() @IsString() scheduleId: string;
|
||||
@ApiProperty() @IsString() holdId: string;
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
|
||||
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||
@ApiProperty({ description: 'Passenger ID' })
|
||||
@IsString() passengerId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||
@IsString() seatClassId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class CreateRoundTripBookingDto {
|
||||
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
|
||||
@ApiProperty({
|
||||
example: 'ONE_WAY',
|
||||
enum: ['ONE_WAY', 'ROUND_TRIP'],
|
||||
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
|
||||
default: 'ONE_WAY'
|
||||
})
|
||||
@IsOptional() @IsString() bookingType?: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
|
||||
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
|
||||
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
|
||||
@ApiProperty({
|
||||
type: [PassengerInputDto],
|
||||
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
||||
passengers: PassengerInputDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
|
||||
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
|
||||
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
|
||||
@ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' })
|
||||
@IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
|
||||
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
|
||||
// Round-trip specific fields
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
|
||||
})
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
|
||||
})
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
@IsOptional() @IsString() returnHoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
|
||||
})
|
||||
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
|
||||
@@ -246,15 +246,19 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
if (dto.bookingType === 'ROUND_TRIP') {
|
||||
return this.createRoundTripBooking(dto);
|
||||
}
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
@@ -262,18 +266,182 @@ export class BookingsService {
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
const fareCalculation = 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);
|
||||
}
|
||||
|
||||
const seatIds = dto.passengers.map((p) => p.seatId);
|
||||
const passengersData = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: fareCalculation.totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
for (const passenger of dto.passengers) {
|
||||
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
return { ...booking, fareBreakdown: fareCalculation };
|
||||
}
|
||||
|
||||
private async createRoundTripBooking(dto: CreateBookingDto) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('Return trip details required for round-trip booking');
|
||||
}
|
||||
|
||||
const [outboundHold, returnHold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
|
||||
]);
|
||||
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
|
||||
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.returnScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||
|
||||
if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) {
|
||||
throw new NotFoundException('Origin or destination stops not found');
|
||||
}
|
||||
|
||||
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
|
||||
const [outboundFare, returnFare] = await Promise.all([
|
||||
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
|
||||
]);
|
||||
|
||||
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
|
||||
const returnSeatIds = passengersData.map(p => p.returnSeatId);
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds)
|
||||
]);
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
outboundFare: outboundFare.baseFareMinor,
|
||||
returnFare: returnFare.baseFareMinor,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async processPassengers(passengers: any[]) {
|
||||
const processedPassengers = [];
|
||||
for (const passenger of passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
@@ -292,68 +460,93 @@ export class BookingsService {
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
|
||||
private async processRoundTripPassengers(passengers: any[]) {
|
||||
const processedPassengers = [];
|
||||
for (const passenger of passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
nationality = nationality || 'Ethiopian';
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
|
||||
private countPassengers(passengersData: any[]) {
|
||||
let adultCount = 0, childCount = 0;
|
||||
for (const passenger of passengersData) {
|
||||
if (passenger.category === PassengerCategory.ADULT) adultCount++;
|
||||
else childCount++;
|
||||
}
|
||||
return { adultCount, childCount };
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
originStop: any,
|
||||
destStop: any,
|
||||
nationality?: string,
|
||||
adultCount = 1,
|
||||
childCount = 0,
|
||||
promoCode?: string,
|
||||
loyaltyRedemptionPoints?: number
|
||||
) {
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
bookingType: dto.bookingType ?? 'ONE_WAY',
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(seatIds);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
|
||||
baseFareMinor,
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -134,9 +134,28 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteCoachType(id: string) {
|
||||
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
|
||||
const coachType = await this.prisma.coachType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
coaches: true,
|
||||
seatClasses: true,
|
||||
},
|
||||
});
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
|
||||
// Check for related records
|
||||
if (coachType.coaches.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
|
||||
);
|
||||
}
|
||||
|
||||
if (coachType.seatClasses.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.coachType.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -183,9 +202,29 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteClass(id: string) {
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
const seatClass = await this.prisma.seatClass.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
fareRules: true,
|
||||
routeFareRules: true,
|
||||
segmentFares: true,
|
||||
},
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
// Check for related records
|
||||
const relatedRecords = [
|
||||
...seatClass.fareRules,
|
||||
...seatClass.routeFareRules,
|
||||
...seatClass.segmentFares,
|
||||
];
|
||||
|
||||
if (relatedRecords.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -220,8 +259,21 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
const train = await this.prisma.train.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
},
|
||||
});
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
|
||||
// Check for active schedules
|
||||
if (train.schedules.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -305,10 +357,53 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
const coach = await this.prisma.coach.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assignments: true,
|
||||
seats: {
|
||||
include: {
|
||||
bookingSeats: true,
|
||||
blocks: true,
|
||||
ticketSeats: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Delete related seats first
|
||||
// Check for active assignments
|
||||
if (coach.assignments.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for booked seats
|
||||
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
|
||||
if (bookedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for blocked seats
|
||||
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
|
||||
if (blockedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for tickets
|
||||
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
|
||||
if (seatsWithTickets.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
|
||||
);
|
||||
}
|
||||
|
||||
// Delete related seats first (now safe to do)
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
|
||||
@@ -545,8 +545,13 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.coaches && dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
} else {
|
||||
// Remove all coach assignments when empty array is sent
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
}
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
|
||||
@@ -204,14 +204,11 @@ export default function SchedulesPage() {
|
||||
departureAt: editForm.departureAt,
|
||||
arrivalAt: editForm.arrivalAt,
|
||||
status: editForm.status,
|
||||
};
|
||||
|
||||
if (editForm.coachIds.length > 0) {
|
||||
payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coachId,
|
||||
positionNumber: idx + 1,
|
||||
}));
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
await updateScheduleMutation.mutateAsync({
|
||||
id: editingSchedule.id,
|
||||
@@ -327,14 +324,20 @@ export default function SchedulesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'originStation.name',
|
||||
label: 'From',
|
||||
render: (schedule: Schedule) => <span>{schedule.originStation?.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'destinationStation.name',
|
||||
label: 'To',
|
||||
render: (schedule: Schedule) => <span>{schedule.destinationStation?.name}</span>,
|
||||
key: 'route',
|
||||
label: 'Route',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{schedule.originStation?.name || 'Unknown'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-sm font-medium">
|
||||
{schedule.destinationStation?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'departureAt',
|
||||
|
||||
@@ -420,7 +420,12 @@ export default function SeatsPage() {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
return seats.length > 0;
|
||||
})
|
||||
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
|
||||
.sort((a: any, b: any) => {
|
||||
// Try multiple sequence field possibilities
|
||||
const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999;
|
||||
const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999;
|
||||
return seqA - seqB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -460,8 +465,30 @@ export default function SeatsPage() {
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -526,7 +553,7 @@ export default function SeatsPage() {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1;
|
||||
const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1;
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function StationsPage() {
|
||||
lat: parseFloat(formData.get('lat') as string) || null,
|
||||
lng: parseFloat(formData.get('lng') as string) || null,
|
||||
timezone: formData.get('timezone') as string,
|
||||
distance: parseFloat(formData.get('distance') as string) || 0,
|
||||
sequence,
|
||||
isOperational: formData.get('isOperational') === 'true',
|
||||
};
|
||||
@@ -136,13 +135,7 @@ export default function StationsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'distance',
|
||||
label: 'Distance (km)',
|
||||
render: (station: any) => (
|
||||
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'isOperational',
|
||||
label: 'Status',
|
||||
@@ -346,18 +339,6 @@ export default function StationsPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Distance from Previous (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="distance"
|
||||
className="input"
|
||||
defaultValue={editingStation?.distance || 0}
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g., 150.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Sequence Number *</label>
|
||||
<input
|
||||
|
||||
@@ -14,38 +14,40 @@ export default function ResultsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||
const [outboundSelected, setOutboundSelected] = useState(false);
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
|
||||
// Prefer URL params; fall back to persisted store values
|
||||
const searchData = {
|
||||
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
|
||||
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
|
||||
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
|
||||
};
|
||||
|
||||
// Sync URL params back into store whenever they are present in the URL
|
||||
useEffect(() => {
|
||||
if (searchParams.get('origin')) {
|
||||
setSearchCriteria({
|
||||
tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP',
|
||||
originStationId: searchParams.get('origin')!,
|
||||
destinationStationId: searchParams.get('destination')!,
|
||||
departureDate: searchParams.get('date')!,
|
||||
returnDate: searchParams.get('returnDate') || undefined,
|
||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||
childCount: parseInt(searchParams.get('children') || '0'),
|
||||
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
|
||||
promoCode: searchParams.get('promoCode') || '',
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams, setSearchCriteria]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchData.promoCode) {
|
||||
@@ -60,45 +62,91 @@ export default function ResultsPage() {
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Promo validation failed:', err);
|
||||
.catch(() => {
|
||||
// Promo validation failed - silently ignore
|
||||
});
|
||||
}
|
||||
}, [searchData.promoCode]);
|
||||
|
||||
const buildSearchUrl = () => {
|
||||
const params = new URLSearchParams({
|
||||
tripType: searchData.journeyType,
|
||||
origin: searchData.originStationId,
|
||||
destination: searchData.destinationStationId,
|
||||
date: searchData.date,
|
||||
adults: searchData.adultCount.toString(),
|
||||
children: searchData.childCount.toString(),
|
||||
nationality: searchData.nationality,
|
||||
...(searchData.returnDate && { returnDate: searchData.returnDate }),
|
||||
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||
});
|
||||
return `/booking/search?${params}`;
|
||||
};
|
||||
|
||||
const { data: results, isLoading, error } = useQuery<Schedule[]>({
|
||||
const { data: results, isLoading, error } = useQuery<any>({
|
||||
queryKey: ['search', searchData],
|
||||
queryFn: async (): Promise<Schedule[]> => {
|
||||
console.log('Searching with criteria:', searchData);
|
||||
const response = await apiClient.post('/search', searchData) as Schedule[];
|
||||
console.log('Search results:', response);
|
||||
console.log('Number of results:', response?.length || 0);
|
||||
if (response?.length > 0) {
|
||||
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
|
||||
queryFn: async (): Promise<any> => {
|
||||
const payload: any = {
|
||||
originStationId: searchData.originStationId,
|
||||
destinationStationId: searchData.destinationStationId,
|
||||
date: searchData.date,
|
||||
adultCount: searchData.adultCount,
|
||||
childCount: searchData.childCount,
|
||||
nationality: searchData.nationality,
|
||||
journeyType: searchData.journeyType,
|
||||
};
|
||||
|
||||
if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) {
|
||||
payload.returnDate = searchData.returnDate;
|
||||
}
|
||||
|
||||
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await apiClient.post('/search', payload) as any;
|
||||
|
||||
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
return response;
|
||||
},
|
||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||
});
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
|
||||
|
||||
// Handle both response formats:
|
||||
// 1. One-way: response is array of schedules
|
||||
// 2. Round-trip: response has journeyType, outbound, inbound properties
|
||||
let outboundSchedules: Schedule[] = [];
|
||||
let inboundSchedules: Schedule[] = [];
|
||||
|
||||
if (results) {
|
||||
if (isRoundTrip && results.journeyType === 'ROUND_TRIP') {
|
||||
// Round trip response format
|
||||
outboundSchedules = results.outbound || [];
|
||||
inboundSchedules = results.inbound || [];
|
||||
} else if (Array.isArray(results)) {
|
||||
// One-way response format (array of schedules)
|
||||
outboundSchedules = results;
|
||||
} else if (results.data && Array.isArray(results.data)) {
|
||||
// Fallback: wrapped in data property
|
||||
outboundSchedules = results.data;
|
||||
}
|
||||
}
|
||||
|
||||
// For one-way, check if outbound has results
|
||||
// For round-trip, check if BOTH outbound and inbound have results
|
||||
const hasResults = isRoundTrip
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
|
||||
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||
if (isOutbound && isRoundTrip) {
|
||||
setOutboundSelected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule) => {
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
|
||||
@@ -120,7 +168,7 @@ export default function ResultsPage() {
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
|
||||
setSelectedSchedule({
|
||||
const scheduleData = {
|
||||
id: scheduleId,
|
||||
trainNumber: schedule.trainNumber,
|
||||
origin: schedule.origin?.name || 'Origin',
|
||||
@@ -132,10 +180,113 @@ export default function ResultsPage() {
|
||||
baseFareChild: selectedClassFare.baseFareMinor,
|
||||
selectedSeatClass: selectedClass,
|
||||
selectedSeatClassName: selectedClass,
|
||||
});
|
||||
};
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
if (isRoundTrip && isOutbound) {
|
||||
setOutboundSelected(true);
|
||||
setClassModal(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// For round trip inbound or one-way, proceed to next step
|
||||
setSelectedSchedule(scheduleData);
|
||||
router.push('/booking/auth-check');
|
||||
};
|
||||
|
||||
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||
: null;
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||
{selectedClass && (
|
||||
<p className="text-xs text-primary font-semibold mb-2">
|
||||
{selectedClass.replace(/_/g, ' ')} selected
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedClass ? 'Change class' : 'Select class'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
@@ -164,7 +315,7 @@ export default function ResultsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -192,19 +343,16 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
{/* Class selection modal */}
|
||||
{classModal && (() => {
|
||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const isOutbound = (classModal as any).isOutbound;
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||
{/* Drawer */}
|
||||
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
|
||||
@@ -222,7 +370,6 @@ export default function ResultsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Class grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -234,7 +381,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<button
|
||||
key={fareClass.seatClassName}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
|
||||
disabled={!isAvailable}
|
||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
isSelected
|
||||
@@ -272,14 +419,13 @@ export default function ResultsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
|
||||
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
|
||||
disabled={!selectedClass}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
<span>Continue</span>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedClass && (
|
||||
@@ -292,7 +438,6 @@ export default function ResultsPage() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Promo Notification */}
|
||||
{promoData && (
|
||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
@@ -329,101 +474,42 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{results.map((schedule) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||
: null;
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
{/* Train info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fare + action */}
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||
{selectedClass && (
|
||||
<p className="text-xs text-primary font-semibold mb-2">
|
||||
{selectedClass.replace(/_/g, ' ')} selected
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setClassModal(schedule)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedClass ? 'Change class' : 'Select class'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{outboundSchedules.length > 0 && (
|
||||
<div>
|
||||
{isRoundTrip && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary" />
|
||||
Outbound Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
|
||||
Return Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,11 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1, 'Please select origin station'),
|
||||
destinationStationId: z.string().min(1, 'Please select destination station'),
|
||||
departureDate: z.string().min(1, 'Please select departure date'),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
@@ -27,6 +29,22 @@ const searchSchema = z.object({
|
||||
}).refine((d) => d.originStationId !== d.destinationStationId, {
|
||||
message: 'Origin and destination must be different',
|
||||
path: ['destinationStationId'],
|
||||
}).refine((d) => {
|
||||
if (d.tripType === 'ROUND_TRIP' && !d.returnDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Please select return date',
|
||||
path: ['returnDate'],
|
||||
}).refine((d) => {
|
||||
if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) {
|
||||
return d.returnDate >= d.departureDate;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Return date must be after departure date',
|
||||
path: ['returnDate'],
|
||||
});
|
||||
|
||||
type SearchForm = z.infer<typeof searchSchema>;
|
||||
@@ -423,6 +441,7 @@ export default function SearchPage() {
|
||||
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
tripType: 'ONE_WAY',
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
nationality: 'ETHIOPIAN',
|
||||
@@ -468,6 +487,8 @@ export default function SearchPage() {
|
||||
const adultCount = watch('adultCount');
|
||||
const childCount = watch('childCount');
|
||||
const departureDate = watch('departureDate');
|
||||
const returnDate = watch('returnDate');
|
||||
const tripType = watch('tripType');
|
||||
const totalPassengers = (adultCount || 1) + (childCount || 0);
|
||||
|
||||
const saveRecent = useCallback((id: string) => {
|
||||
@@ -510,12 +531,14 @@ export default function SearchPage() {
|
||||
if (data.originStationId) saveRecent(data.originStationId);
|
||||
if (data.destinationStationId) saveRecent(data.destinationStationId);
|
||||
const params = new URLSearchParams({
|
||||
tripType: data.tripType,
|
||||
origin: data.originStationId,
|
||||
destination: data.destinationStationId,
|
||||
date: data.departureDate,
|
||||
adults: data.adultCount.toString(),
|
||||
children: data.childCount.toString(),
|
||||
nationality: data.nationality,
|
||||
...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }),
|
||||
...(data.promoCode && { promoCode: data.promoCode }),
|
||||
});
|
||||
router.push(`/booking/results?${params}`);
|
||||
@@ -615,6 +638,34 @@ export default function SearchPage() {
|
||||
|
||||
<div className="p-4 md:p-5">
|
||||
|
||||
{/* Trip Type Tabs */}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('tripType', 'ONE_WAY')}
|
||||
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
tripType === 'ONE_WAY'
|
||||
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
One Way
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('tripType', 'ROUND_TRIP')}
|
||||
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
tripType === 'ROUND_TRIP'
|
||||
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Round Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="space-y-1.5">
|
||||
@@ -660,6 +711,20 @@ export default function SearchPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return Date</label>
|
||||
<div className="relative z-20">
|
||||
<ModernDatePicker
|
||||
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
|
||||
placeholder="Select return date"
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Pax + Nationality combined trigger */}
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
|
||||
@@ -676,102 +741,230 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
|
||||
<div className="hidden md:flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</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">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Pax + Nationality combined — opens shared modal */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Promo */}
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
{/* Desktop: dynamic layout based on trip type */}
|
||||
<div className={`hidden md:block`}>
|
||||
{tripType === 'ONE_WAY' ? (
|
||||
// ONE WAY: Single row layout
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</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">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Departure"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</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">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
{/* Search */}
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// ROUND TRIP: Two row layout
|
||||
<div className="space-y-3">
|
||||
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Departure Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Return Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
|
||||
<div className="relative z-20">
|
||||
<ModernDatePicker
|
||||
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Promo, Passengers, Search */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Promo Code */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
{!promoVisible ? 'Promo Code (Optional)' : 'Promo Code'}
|
||||
</label>
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left">
|
||||
<Gift className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm text-gray-400">Click to add promo code</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 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">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all">
|
||||
<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 · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Search Button */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">Search</label>
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Promo - Only visible in ONE WAY mode on desktop */}
|
||||
{tripType === 'ONE_WAY' && (
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function SeatsPage() {
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
|
||||
const [modalState, setModalState] = useState({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
@@ -57,6 +58,8 @@ export default function SeatsPage() {
|
||||
type: 'info' as 'warning' | 'error' | 'success' | 'info',
|
||||
});
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const { data: seatMapData, isLoading, error } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule?.id],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
|
||||
@@ -70,10 +73,15 @@ export default function SeatsPage() {
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
|
||||
// For round trip inbound, swap origin and destination
|
||||
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
|
||||
const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId;
|
||||
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
|
||||
|
||||
return apiClient.post(`/seats/hold`, {
|
||||
scheduleId: selectedSchedule?.id,
|
||||
originStationId: searchCriteria?.originStationId,
|
||||
destinationStationId: searchCriteria?.destinationStationId,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
passengers: passengersForHold,
|
||||
});
|
||||
},
|
||||
@@ -157,17 +165,58 @@ export default function SeatsPage() {
|
||||
}, [passengers.length]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isRoundTrip && currentJourneyType === 'outbound') {
|
||||
// Save outbound seats and show inbound
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
} catch (error: any) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: 'Seat Hold Failed',
|
||||
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentJourneyType('inbound');
|
||||
setSelectedSeats([]);
|
||||
setSelectedCoach(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final continue (one-way or round-trip inbound)
|
||||
if (selectedSeats.length > 0) {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
} catch (error: any) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: 'Seat Hold Failed',
|
||||
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push('/booking/review');
|
||||
};
|
||||
@@ -449,7 +498,14 @@ export default function SeatsPage() {
|
||||
disabled={selectedSeats.length === 0 || holdMutation.isPending}
|
||||
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||
>
|
||||
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
|
||||
{holdMutation.isPending
|
||||
? 'Holding seats...'
|
||||
: isRoundTrip && currentJourneyType === 'outbound'
|
||||
? 'Continue to Return Seats'
|
||||
: allSelected
|
||||
? 'Continue'
|
||||
: 'Continue with partial selection'
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
@@ -496,7 +552,12 @@ export default function SeatsPage() {
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{isRoundTrip
|
||||
? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats')
|
||||
: 'Select Seats'
|
||||
}
|
||||
</h1>
|
||||
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
|
||||
{selectedSeats.length}/{passengers.length}
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,11 @@ import { useState } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1),
|
||||
destinationStationId: z.string().min(1),
|
||||
departureDate: z.string().min(1),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
@@ -44,6 +46,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
tripType: 'ONE_WAY',
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
nationality: 'ETHIOPIAN',
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface SearchCriteria {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
departureDate: string;
|
||||
returnDate?: string;
|
||||
tripType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
TURBO_FILTER: "@edr/freight-portal"
|
||||
APP_PATH: apps/edr-freight-web/portal
|
||||
# Browser-reachable URL; override for production deployments
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
@@ -70,7 +70,7 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-backoffice"
|
||||
APP_PATH: apps/edr-freight-web/backoffice
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ARG TURBO_FILTER=@edr/freight-portal
|
||||
ARG APP_PATH=apps/edr-freight-web/portal
|
||||
ARG VITE_API_URL=http://localhost:3001/api
|
||||
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
|
||||
Reference in New Issue
Block a user