Merge pull request #519 from Tria-plc/dev

merg
This commit is contained in:
Abubeker Yasin
2026-07-07 23:58:39 +03:00
committed by GitHub
64 changed files with 4270 additions and 789 deletions

View File

@@ -31,10 +31,12 @@
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/microservices": "^11.1.24",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
@@ -54,6 +56,7 @@
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",

View File

@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "SupportConversation" ADD COLUMN "agentLastReadAt" TIMESTAMP(3),
ADD COLUMN "lastMessageAt" TIMESTAMP(3),
ADD COLUMN "lastMessagePreview" TEXT,
ADD COLUMN "lastMessageSender" "SupportSender",
ADD COLUMN "passengerId" TEXT,
ADD COLUMN "passengerName" TEXT,
ADD COLUMN "subject" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "userLastReadAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "SupportConversation_userId_idx" ON "SupportConversation"("userId");
-- CreateIndex
CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt");
-- AddForeignKey
ALTER TABLE "SupportConversation" ADD CONSTRAINT "SupportConversation_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "SupportConversation" ADD COLUMN "guestEmail" TEXT,
ADD COLUMN "guestId" TEXT,
ADD COLUMN "guestName" TEXT,
ADD COLUMN "guestPhone" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "SupportConversation_guestId_idx" ON "SupportConversation"("guestId");

View File

@@ -299,6 +299,7 @@ model Passenger {
travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
packageBookings PackageBooking[]
supportConversations SupportConversation[]
@@index([userId])
@@index([iamUserId])
@@schema("passenger")
@@ -881,12 +882,29 @@ model FaqArticle {
}
model SupportConversation {
id String @id @default(uuid())
userId String
assignedAgentId String?
status SupportConversationStatus @default(OPEN)
createdAt DateTime @default(now())
messages SupportMessage[]
id String @id @default(uuid())
userId String?
guestId String?
guestName String?
guestEmail String?
guestPhone String?
passengerId String?
passengerName String?
subject String?
assignedAgentId String?
status SupportConversationStatus @default(OPEN)
lastMessageAt DateTime?
lastMessagePreview String?
lastMessageSender SupportSender?
userLastReadAt DateTime?
agentLastReadAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
messages SupportMessage[]
passenger Passenger? @relation(fields: [passengerId], references: [id])
@@index([userId])
@@index([guestId])
@@index([status, lastMessageAt])
@@schema("passenger")
}

View File

@@ -64,7 +64,6 @@ import { TasksModule } from './modules/tasks/tasks.module';
import { AppReleasesModule } from './modules/app-releases/app-releases.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
import { SegmentFareSeeder } from './seed/segment-fare.seeder';
import { EOtpType } from "@tria-plc/iamapi-common";
@Module({
imports: [
@@ -98,16 +97,6 @@ import { EOtpType } from "@tria-plc/iamapi-common";
TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS,
otpMessages: {
[EOtpType.MFA_LOGIN]: ({ otp }) =>
`Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) =>
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.RESET_PASSWORD]: ({ route }) =>
`Reset your EDR Passenger password using this link: ${route}`,
[EOtpType.SET_PASSWORD]: ({ route }) =>
`Set your EDR Passenger password using this link: ${route}`,
},
}),
SharedAuthModule,
PrismaModule,

View File

@@ -4,7 +4,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto {
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types). Omit for free children (package bookings) — backend auto-assigns.' }) @IsOptional() @IsString() seatId?: string;
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@@ -24,11 +24,11 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
@ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
@IsString() outboundSeatId: string;
@ApiPropertyOptional({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
@IsOptional() @IsString() outboundSeatId?: string;
@ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
@IsString() returnSeatId: string;
@ApiPropertyOptional({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
@IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' })
@IsOptional() @IsString() outboundLeg2SeatId?: string;

View File

@@ -4,8 +4,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types). Omit for free children (package bookings) — backend auto-assigns.' })
@IsOptional() @IsString() seatId?: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnSeatId?: string;

View File

@@ -260,7 +260,7 @@ export class GuestBookingService {
await this.createTravelerProfiles(guestPassengerId, passengersData);
// Confirm seats
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id));
this.eventEmitter.emit('booking.created', { booking });
return {
@@ -434,7 +434,7 @@ export class GuestBookingService {
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId);
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
@@ -701,7 +701,7 @@ export class GuestBookingService {
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
]);
this.eventEmitter.emit('booking.created', { booking });
@@ -894,7 +894,7 @@ export class GuestBookingService {
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),

View File

@@ -285,7 +285,11 @@ export class NotificationsService {
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`;
// /booking/confirmation only reads from the in-session booking store, so it's a dead
// link once opened outside that session (a different device, or later on the same
// one) — exactly the case an SMS/email link is for. /booking/detail fetches the
// booking fresh from the API by ref, so it works standalone.
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
// IN_APP — always created.
await this.createInAppNotification(

View File

@@ -75,7 +75,6 @@ export class PackagesService {
const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT;
if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`);
const remaining = tier.availableSeats - tier.bookedSeats;
const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount,
@@ -84,6 +83,10 @@ export class PackagesService {
// Only adults and paid children need seats; free children travel without a seat
const seatsNeeded = adultCount + paidChildren;
const passengerCount = adultCount + childCount;
// Re-fetch tier from DB to get accurate live counts
const liveTier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
if (!liveTier) throw new NotFoundException('Price tier not found');
const remaining = liveTier.availableSeats - liveTier.bookedSeats;
if (seatsNeeded > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
@@ -392,25 +395,29 @@ export class PackagesService {
if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`);
const passengerCount = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats;
const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount,
);
// Only adults and paid children need seats; free children travel without a seat
const seatsNeeded = adultCount + paidChildren;
if (seatsNeeded > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const [booking] = await this.prisma.$transaction([
this.prisma.packageBooking.create({
const [booking] = await this.prisma.$transaction(async (tx) => {
// Re-fetch tier inside transaction for race-condition-safe availability check
const freshTier = await tx.packagePriceTier.findUnique({ where: { id: dto.priceTierId } });
if (!freshTier) throw new NotFoundException('Price tier not found');
const remaining = freshTier.availableSeats - freshTier.bookedSeats;
if (seatsNeeded > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
return Promise.all([
tx.packageBooking.create({
data: {
bookingRef: generateRef(),
packageId: dto.packageId,
@@ -448,12 +455,16 @@ export class PackagesService {
},
},
},
}),
this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: seatsNeeded } },
}),
]);
}),
tx.packagePriceTier.update({
where: { id: dto.priceTierId },
data: {
bookedSeats: { increment: seatsNeeded },
availableSeats: { decrement: seatsNeeded },
},
}),
]);
});
return {
...booking,

View File

@@ -33,6 +33,7 @@ import {
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
ForceConfirmDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -124,6 +125,19 @@ export class PaymentsController {
return this.service.refund(dto);
}
@Post(":bookingId/force-confirm")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Force-confirm payment & generate ticket (back-office only)",
description:
"Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " +
"Use when a vendor payment completed but the webhook was never delivered. Idempotent.",
})
forceConfirm(@Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto) {
return this.service.forceConfirmPayment(bookingId, dto);
}
@Post("methods")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")

View File

@@ -142,3 +142,14 @@ export class BookingAmountResponseDto {
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
}
export class ForceConfirmDto {
@ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' })
@IsOptional() @IsString() paymentReference?: string;
@ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' })
@IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' })
@IsOptional() @IsString() notes?: string;
}

View File

@@ -21,6 +21,7 @@ import {
InitiateResponseDto,
IntentStatusDto,
PaymentRegionEnum,
ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
@@ -773,6 +774,51 @@ export class PaymentsService {
return { processed: true, alreadyFinalized };
}
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new NotFoundException('Booking not found');
const resolvedMethod = dto.paymentMethod
? (dto.paymentMethod as unknown as PaymentMethodType)
: PaymentMethodType.TELEBIRR;
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: resolvedMethod,
status: PaymentIntentStatus.PROCESSING,
providerRef: `FORCE-${Date.now()}`,
providerTxnId: dto.paymentReference ?? null,
failureMessage: dto.notes ?? null,
},
});
} else {
// Update method/reference/notes regardless of current status
const updateData: any = {};
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
if (dto.paymentMethod) updateData.method = resolvedMethod;
if (dto.notes) updateData.failureMessage = dto.notes;
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
updateData.status = PaymentIntentStatus.PROCESSING;
}
if (Object.keys(updateData).length) {
intent = await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: updateData,
});
}
}
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
});
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;

View File

@@ -64,7 +64,7 @@ export class SearchService {
const outbound = [...direct, ...transit];
if (outbound.length === 0) {
if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
@@ -74,7 +74,7 @@ export class SearchService {
dto.nationality,
);
return {
journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
journeyType: 'ONE_WAY',
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
@@ -110,19 +110,29 @@ export class SearchService {
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
if (inbound.length === 0) {
const alternativeInbound = await this.searchAlternatives(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound };
const returnDate = dto.returnDate ?? dto.date;
if (outbound.length === 0 || inbound.length === 0) {
const [alternativeOutbound, alternativeInbound] = await Promise.all([
outbound.length === 0
? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality)
: Promise.resolve([]),
inbound.length === 0
? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality)
: Promise.resolve([]),
]);
return {
journeyType: 'ROUND_TRIP',
outbound,
inbound,
alternativeOutbound,
alternativeInbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
};
}
return { journeyType: 'ROUND_TRIP', outbound, inbound };
return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate };
}
return { journeyType: 'ONE_WAY', outbound };

View File

@@ -19,7 +19,7 @@ import {
ApiResponse,
} from "@nestjs/swagger";
import { SeatsService } from "./seats.service";
import { HoldSeatsDto } from "./seats.dto";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { IamGuard } from "../../common/iam-adapter";
@@ -182,6 +182,27 @@ This makes it clear which segment of the route each seat is held for, enabling s
return this.service.releaseHold(holdId);
}
@Post("release")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Release a seat hold by holdId (portal-server use only)",
description:
"Frees a previously-created hold's seats immediately instead of waiting for it to " +
"expire — used when a guest or logged-in user changes their seat selection, so the " +
"stale hold doesn't linger and block that seat for other travellers.\n\n" +
"This is a public endpoint (no JWT), like POST /seats/hold, since guest sessions have " +
"no login to authenticate with. It must ONLY ever be called from the passenger portal's " +
"own Next.js server (a server-side route handler), never directly from browser code — " +
"calling it straight from client JS would let anyone script mass hold-cancellation " +
"against other travellers' in-progress seat selections. The portal's server-side proxy " +
"is what keeps this endpoint's existence out of the browser's network requests.",
})
@ApiResponse({ status: 200, description: "Hold released" })
@ApiResponse({ status: 404, description: "Hold not found" })
releaseSeatById(@Body() dto: ReleaseHoldDto) {
return this.service.releaseHold(dto.holdId);
}
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block")
@UseGuards(IamGuard)

View File

@@ -48,3 +48,8 @@ export class HoldSeatsDto {
@Type(() => PassengerSeatDto)
passengers: PassengerSeatDto[];
}
export class ReleaseHoldDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
@IsString() holdId: string;
}

View File

@@ -90,6 +90,7 @@ export class SeatsService {
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
coachTypeId: a.coach.coachType?.id ?? null,
coachTypeName,
isBedCoach,
bedCategory,
@@ -665,6 +666,7 @@ export class SeatsService {
return {
coachId: a.coach.id,
coachTypeId: a.coach.coachType?.id ?? null,
coachNumber: a.coach.number,
positionNumber: a.positionNumber,
coachTypeName: a.coach.coachType?.name ?? '',

View File

@@ -1,15 +1,207 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SupportService } from './support.service';
import { JwtGuard } from '../../common/jwt.guard';
import {
CreateConversationDto,
CreateGuestConversationDto,
GuestIdBodyDto,
GuestSendMessageDto,
ListConversationsQueryDto,
SendMessageDto,
UpdateStatusDto,
} from './support.dto';
function userId(req: any): string {
const id = req?.user?.id ?? req?.user?.sub;
if (!id) throw new UnauthorizedException();
return id;
}
@ApiTags('Support')
@Controller('support')
export class SupportController {
constructor(private service: SupportService) {}
@Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); }
@Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); }
@Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); }
@Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); }
@Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); }
// ---- FAQ (public) ------------------------------------------------------
@Get('faq')
@IsPublic()
@ApiOperation({ summary: 'Get FAQ categories' })
getFaqCategories() {
return this.service.getFaqCategories();
}
@Get('faq/:categoryId/articles')
@IsPublic()
@ApiOperation({ summary: 'Get FAQ articles for a category' })
getFaqArticles(@Param('categoryId') id: string) {
return this.service.getFaqArticles(id);
}
// ---- customer: authenticated passenger --------------------------------
@Post('conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Open a new support conversation' })
createConversation(@Req() req: any, @Body() body: CreateConversationDto) {
return this.service.createConversation(userId(req), body);
}
@Get('conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List my support conversations' })
listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) {
return this.service.listForCustomer({ iamUserId: userId(req) }, query);
}
@Get('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in one of my conversations' })
messages(@Req() req: any, @Param('id') id: string) {
return this.service.getMessages(id, { iamUserId: userId(req) });
}
@Post('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Send a message as the customer' })
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
iamUserId: userId(req),
});
}
@Post('conversations/:id/read')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Mark a conversation read (customer side)' })
read(@Req() req: any, @Param('id') id: string) {
return this.service.markRead(id, 'USER', { iamUserId: userId(req) });
}
@Get('unread-count')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Count my unread support conversations' })
unread(@Req() req: any) {
return this.service.unreadCount('USER', { iamUserId: userId(req) });
}
// ---- customer: guest (unauthenticated) --------------------------------
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
// of access — anyone with it sees that thread; accepted MVP trade-off).
@Post('guest/conversations')
@IsPublic()
@ApiOperation({ summary: 'Open a support conversation as a guest' })
guestCreate(@Body() body: CreateGuestConversationDto) {
return this.service.createGuestConversation(body);
}
@Get('guest/conversations')
@IsPublic()
@ApiOperation({ summary: 'List a guest\'s conversations' })
guestList(
@Query('guestId') guestId: string,
@Query() query: ListConversationsQueryDto,
) {
return this.service.listForCustomer({ guestId }, query);
}
@Get('guest/conversations/:id/messages')
@IsPublic()
@ApiOperation({ summary: 'List messages in a guest conversation' })
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
return this.service.getMessages(id, { guestId });
}
@Post('guest/conversations/:id/messages')
@IsPublic()
@ApiOperation({ summary: 'Send a message as a guest' })
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
guestId: body.guestId,
});
}
@Post('guest/conversations/:id/read')
@IsPublic()
@ApiOperation({ summary: 'Mark a guest conversation read' })
guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) {
return this.service.markRead(id, 'USER', { guestId: body.guestId });
}
@Get('guest/unread-count')
@IsPublic()
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
guestUnread(@Query('guestId') guestId: string) {
return this.service.unreadCount('USER', { guestId });
}
// ---- agent (backoffice) ------------------------------------------------
// TODO: gate agent routes with a staff permission once passenger RBAC is wired.
@Get('agent/conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all support conversations (shared inbox)' })
agentList(@Query() query: ListConversationsQueryDto) {
return this.service.listForAgents(query);
}
@Get('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in a conversation' })
agentMessages(@Param('id') id: string) {
return this.service.getMessages(id);
}
@Post('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reply as an agent' })
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'AGENT', body.text);
}
@Patch('agent/conversations/:id/status')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: "Change a conversation's status" })
agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) {
return this.service.setStatus(id, body.status);
}
@Post('agent/conversations/:id/read')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Mark a conversation read (agent side)' })
agentRead(@Param('id') id: string) {
return this.service.markRead(id, 'AGENT');
}
@Get('agent/unread-count')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Count unread conversations (agent side)' })
agentUnread() {
return this.service.unreadCount('AGENT');
}
}

View File

@@ -0,0 +1,127 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Length,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum SupportStatusDto {
OPEN = 'OPEN',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
export class CreateConversationDto {
@ApiProperty({ description: 'Short subject / topic of the request.' })
@IsString()
@Length(3, 200)
subject!: string;
@ApiProperty({ description: 'The first message body.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
initialMessage!: string;
}
export class SendMessageDto {
@ApiProperty({ description: 'Message text.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
text!: string;
}
export class CreateGuestConversationDto {
@ApiProperty({ description: 'Client-generated anonymous id (localStorage).' })
@IsString()
@Length(8, 120)
guestId!: string;
@ApiProperty({ description: 'Guest full name.' })
@IsString()
@Length(1, 120)
name!: string;
@ApiProperty({ description: 'Guest email for follow-up.' })
@IsEmail()
email!: string;
@ApiPropertyOptional({ description: 'Guest phone (optional).' })
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@ApiProperty()
@IsString()
@Length(3, 200)
subject!: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(4000)
initialMessage!: string;
}
export class GuestSendMessageDto {
@ApiProperty({ description: 'The guest id that owns the conversation.' })
@IsString()
@Length(8, 120)
guestId!: string;
@ApiProperty({ description: 'Message text.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
text!: string;
}
export class GuestIdBodyDto {
@ApiProperty()
@IsString()
@Length(8, 120)
guestId!: string;
}
export class UpdateStatusDto {
@ApiProperty({ enum: SupportStatusDto })
@IsEnum(SupportStatusDto)
status!: SupportStatusDto;
}
export class ListConversationsQueryDto {
@ApiPropertyOptional({ enum: SupportStatusDto })
@IsOptional()
@IsEnum(SupportStatusDto)
status?: SupportStatusDto;
@ApiPropertyOptional({ description: 'Search subject / passenger name.' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,134 @@
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Passenger as PassengerTypes } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { WsAuthService } from './ws-auth.service';
/**
* Server → client push for passenger support chat. Clients only *listen* (no
* `@SubscribeMessage`); the handshake is authenticated in `handleConnection`.
* Each socket joins a room based on its side:
* - backoffice staff → the shared `backoffice` room (see every conversation).
* - passengers → their `user:<iamUserId>` room (their own tickets only).
*
* Side is decided by the presence of a `Passenger` row for the IAM user id
* (staff have none). A message is emitted to BOTH the owner's room and the
* backoffice room so the customer thread, the sender's echo, and every agent's
* inbox update live.
*/
@WebSocketGateway({
namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class SupportGateway implements OnGatewayConnection {
private readonly logger = new Logger(SupportGateway.name);
private static readonly BACKOFFICE_ROOM = 'backoffice';
@WebSocketServer()
private readonly server!: Server;
constructor(
private readonly wsAuth: WsAuthService,
private readonly prisma: PrismaService,
) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
// Authenticated: passenger (own room) or backoffice staff (shared room).
if (userId) {
socket.data.userId = userId;
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: userId },
});
if (passenger) {
await socket.join(`user:${userId}`);
socket.data.side = 'USER';
} else {
await socket.join(SupportGateway.BACKOFFICE_ROOM);
socket.data.side = 'AGENT';
}
return;
}
// Guest: no valid token, but a client-generated guestId scopes the room.
// Anyone holding the guestId can see that thread (no account = weaker
// ownership) — an accepted MVP trade-off for guest support.
const guestId = this.extractGuestId(socket);
if (guestId) {
socket.data.guestId = guestId;
socket.data.side = 'USER';
await socket.join(`guest:${guestId}`);
return;
}
this.logger.debug(`Rejected passenger-support handshake ${socket.id}`);
socket.disconnect(true);
}
/** Push a new message + updated conversation to the owner + backoffice rooms. */
emitMessage(
ownerRoom: string | null,
conversation: PassengerTypes.PassengerSupportConversationDto,
message: PassengerTypes.PassengerSupportMessageDto,
): void {
const payload = { conversation, message };
for (const room of this.targetRooms(ownerRoom)) {
const to = this.server.to(room);
to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload);
to.emit(
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
conversation,
);
}
}
/** Push a conversation metadata change (e.g. status) to both rooms. */
emitConversationUpdated(
ownerRoom: string | null,
conversation: PassengerTypes.PassengerSupportConversationDto,
): void {
for (const room of this.targetRooms(ownerRoom)) {
this.server
.to(room)
.emit(
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
conversation,
);
}
}
private targetRooms(ownerRoom: string | null): string[] {
const rooms = [SupportGateway.BACKOFFICE_ROOM];
if (ownerRoom) rooms.push(ownerRoom);
return rooms;
}
private extractGuestId(socket: Socket): string | undefined {
const authGuest = socket.handshake.auth?.guestId as string | undefined;
if (authGuest) return authGuest;
const queryGuest = socket.handshake.query?.guestId;
if (typeof queryGuest === 'string') return queryGuest;
return undefined;
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -1,6 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
import { SupportGateway } from './support.gateway';
import { WsAuthService } from './ws-auth.service';
@Module({ controllers: [SupportController], providers: [SupportService] })
@Module({
// Session is served by the app's default TypeORM DataSource (IAM schema) —
// used by WsAuthService to authenticate WebSocket handshakes.
imports: [TypeOrmModule.forFeature([Session])],
controllers: [SupportController],
providers: [SupportService, SupportGateway, WsAuthService],
})
export class SupportModule {}

View File

@@ -1,35 +1,419 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Passenger as T } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { SupportGateway } from './support.gateway';
type Side = 'USER' | 'AGENT';
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
/** Who the caller is on the customer side: an authed passenger or a guest. */
export interface CustomerOwner {
iamUserId?: string | null;
guestId?: string | null;
}
interface ListQuery {
status?: PrismaStatus;
search?: string;
page?: number;
limit?: number;
}
type ConversationRow = {
id: string;
userId: string | null;
guestId: string | null;
guestName: string | null;
guestEmail: string | null;
guestPhone: string | null;
passengerId: string | null;
passengerName: string | null;
subject: string | null;
status: PrismaStatus;
assignedAgentId: string | null;
lastMessageAt: Date | null;
lastMessagePreview: string | null;
lastMessageSender: PrismaSender | null;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class SupportService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private gateway: SupportGateway,
) {}
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
// ---- FAQ (unchanged) ---------------------------------------------------
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
if (!conv) throw new NotFoundException('Conversation not found');
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
return message;
getFaqCategories() {
return this.prisma.faqCategory.findMany({
include: { _count: { select: { articles: true } } },
});
}
async getConversation(conversationId: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
if (!conv) throw new NotFoundException('Conversation not found');
return conv;
getFaqArticles(categoryId: string) {
return this.prisma.faqArticle.findMany({
where: { categoryId },
orderBy: { rank: 'asc' },
});
}
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
// ---- customer: authed passenger ---------------------------------------
async createConversation(
iamUserId: string,
input: { subject: string; initialMessage: string },
): Promise<T.PassengerSupportConversationDto> {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId },
include: { user: { select: { fullName: true } } },
});
const conversation = (await this.prisma.supportConversation.create({
data: {
userId: iamUserId,
passengerId: passenger?.id ?? null,
passengerName: passenger?.user?.fullName ?? null,
subject: input.subject,
status: 'OPEN',
},
})) as ConversationRow;
return this.firstMessage(conversation, input.initialMessage);
}
// ---- customer: guest (unauthenticated) --------------------------------
async createGuestConversation(input: {
guestId: string;
name: string;
email: string;
phone?: string;
subject: string;
initialMessage: string;
}): Promise<T.PassengerSupportConversationDto> {
const conversation = (await this.prisma.supportConversation.create({
data: {
guestId: input.guestId,
guestName: input.name,
guestEmail: input.email,
guestPhone: input.phone ?? null,
passengerName: input.name, // uniform display name for the agent inbox
subject: input.subject,
status: 'OPEN',
},
})) as ConversationRow;
return this.firstMessage(conversation, input.initialMessage);
}
async listForCustomer(
owner: CustomerOwner,
query: ListQuery,
): Promise<T.PassengerSupportConversationListResult> {
const scope = this.ownerScope(owner);
const where = { ...this.listWhere(query), ...scope };
const rows = (await this.prisma.supportConversation.findMany({
where,
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
take: query.limit ?? 100,
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
})) as ConversationRow[];
const count = await this.prisma.supportConversation.count({ where });
return this.buildListResult(rows, count, 'USER');
}
// ---- agent (backoffice) ------------------------------------------------
async listForAgents(
query: ListQuery,
): Promise<T.PassengerSupportConversationListResult> {
const where = this.listWhere(query);
const rows = (await this.prisma.supportConversation.findMany({
where,
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
take: query.limit ?? 100,
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
})) as ConversationRow[];
const count = await this.prisma.supportConversation.count({ where });
return this.buildListResult(rows, count, 'AGENT');
}
async setStatus(
conversationId: string,
status: PrismaStatus,
): Promise<T.PassengerSupportConversationDto> {
await this.requireConversation(conversationId);
const updated = (await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { status },
})) as ConversationRow;
const dto = this.toConversationDto(updated, 0);
this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto);
return dto;
}
// ---- shared ------------------------------------------------------------
async getMessages(
conversationId: string,
asCustomer?: CustomerOwner,
): Promise<T.PassengerSupportMessageDto[]> {
const conversation = await this.requireConversation(conversationId);
if (asCustomer) this.assertOwns(conversation, asCustomer);
const rows = await this.prisma.supportMessage.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
});
return rows.map((m) => this.toMessageDto(m));
}
async sendMessage(
conversationId: string,
sender: Side,
text: string,
asCustomer?: CustomerOwner,
): Promise<T.PassengerSupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
if (sender === 'USER') {
this.assertOwns(conversation, asCustomer ?? {});
}
const updated = await this.appendMessage(conversation, sender, text);
const last = updated.messages[updated.messages.length - 1];
return this.toMessageDto(last);
}
async markRead(
conversationId: string,
side: Side,
asCustomer?: CustomerOwner,
): Promise<{ unreadCount: number }> {
const conversation = await this.requireConversation(conversationId);
if (side === 'USER') {
this.assertOwns(conversation, asCustomer ?? {});
await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { userLastReadAt: new Date() },
});
return this.unreadCount('USER', asCustomer);
}
await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { agentLastReadAt: new Date() },
});
return this.unreadCount('AGENT');
}
async unreadCount(
side: Side,
owner?: CustomerOwner,
): Promise<{ unreadCount: number }> {
const rows = (await this.prisma.supportConversation.findMany({
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
})) as Array<{
id: string;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
}>;
const map = await this.computeUnread(rows, side);
let unreadCount = 0;
for (const n of map.values()) if (n > 0) unreadCount++;
return { unreadCount };
}
// ---- internals ---------------------------------------------------------
private async firstMessage(
conversation: ConversationRow,
text: string,
): Promise<T.PassengerSupportConversationDto> {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
'USER',
text,
);
return this.toConversationDto(updated, 0);
}
private async appendMessage(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
) {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
sender,
text,
);
return updated as ConversationRow & { messages: any[] };
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
private async appendMessageRaw(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
const message = await this.prisma.supportMessage.create({
data: { conversationId: conversation.id, sender, text },
});
const updated = (await this.prisma.supportConversation.update({
where: { id: conversation.id },
data: {
lastMessageAt: message.createdAt,
lastMessagePreview: text.slice(0, 280),
lastMessageSender: sender,
},
include: { messages: { orderBy: { createdAt: 'asc' } } },
})) as ConversationRow & { messages: any[] };
const dto = this.toConversationDto(updated, 0);
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
return { conversation: updated, message };
}
private async buildListResult(
rows: ConversationRow[],
count: number,
side: Side,
): Promise<T.PassengerSupportConversationListResult> {
const unreadMap = await this.computeUnread(rows, side);
const items = rows.map((r) =>
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
);
let unreadCount = 0;
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
return { items, count, unreadCount };
}
private async computeUnread(
rows: Array<{
id: string;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
}>,
side: Side,
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (rows.length === 0) return map;
const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER';
const ids = rows.map((r) => r.id);
const msgs = await this.prisma.supportMessage.findMany({
where: { conversationId: { in: ids }, sender: otherSender },
select: { conversationId: true, createdAt: true },
});
const cursorById = new Map(
rows.map((r) => [
r.id,
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
]),
);
for (const m of msgs) {
const cursor = cursorById.get(m.conversationId) ?? null;
if (!cursor || m.createdAt > cursor) {
map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1);
}
}
return map;
}
private listWhere(query: ListQuery) {
const where: Record<string, unknown> = {};
if (query.status) where.status = query.status;
if (query.search?.trim()) {
const contains = query.search.trim();
where.OR = [
{ subject: { contains, mode: 'insensitive' } },
{ passengerName: { contains, mode: 'insensitive' } },
{ guestEmail: { contains, mode: 'insensitive' } },
];
}
return where;
}
/** Prisma where-fragment scoping to the calling customer (authed or guest). */
private ownerScope(owner: CustomerOwner): Record<string, unknown> {
if (owner.iamUserId) return { userId: owner.iamUserId };
if (owner.guestId) return { guestId: owner.guestId };
// No identity ⇒ match nothing.
return { id: '__none__' };
}
private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void {
const ok =
(owner.iamUserId && conversation.userId === owner.iamUserId) ||
(owner.guestId && conversation.guestId === owner.guestId);
if (!ok) {
throw new ForbiddenException('This conversation belongs to someone else.');
}
}
private ownerRoom(c: ConversationRow): string | null {
if (c.guestId) return `guest:${c.guestId}`;
if (c.userId) return `user:${c.userId}`;
return null;
}
private async requireConversation(id: string): Promise<ConversationRow> {
const conversation = (await this.prisma.supportConversation.findUnique({
where: { id },
})) as ConversationRow | null;
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
private toConversationDto(
c: ConversationRow,
unreadCount: number,
): T.PassengerSupportConversationDto {
return {
id: c.id,
userId: c.userId,
guestId: c.guestId,
guestEmail: c.guestEmail,
guestPhone: c.guestPhone,
passengerId: c.passengerId,
passengerName: c.passengerName ?? c.guestName ?? null,
subject: c.subject,
status: c.status as T.PassengerSupportStatus,
assignedAgentId: c.assignedAgentId,
lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null,
lastMessagePreview: c.lastMessagePreview,
lastMessageSender: this.toDtoSender(c.lastMessageSender),
unreadCount,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
};
}
private toMessageDto(m: {
id: string;
conversationId: string;
sender: PrismaSender;
text: string;
createdAt: Date;
}): T.PassengerSupportMessageDto {
return {
id: m.id,
conversationId: m.conversationId,
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
text: m.text,
createdAt: m.createdAt.toISOString(),
};
}
/** Legacy BOT messages are surfaced as AGENT to the UI. */
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
if (!s) return null;
return s === 'USER'
? T.PassengerSupportSender.USER
: T.PassengerSupportSender.AGENT;
}
}

View File

@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { verifyToken } from '@tria-plc/api-common/utils/token';
import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
/**
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
* user — so we verify the signature (`verifyToken`), then load the IAM session
* and require it to be ACTIVE and unexpired, and read the real user id out of
* `session.userInfo`. The `Session` entity is served by the app's default
* TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`).
*
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
*/
@Injectable()
export class WsAuthService {
private readonly logger = new Logger(WsAuthService.name);
constructor(
@InjectRepository(Session)
private readonly sessions: Repository<Session>,
) {}
async resolveUserId(token?: string): Promise<string | null> {
if (!token) return null;
try {
const payload = verifyToken(token) as { id?: string };
const sessionId = payload?.id;
if (!sessionId) return null;
const session = await this.sessions.findOne({ where: { id: sessionId } });
if (!session) return null;
if (session.status !== ESessionStatus.ACTIVE) return null;
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
return null;
}
return session.userInfo?.id ?? null;
} catch (err) {
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -258,8 +258,8 @@ export class TicketsService {
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
seat: ps.seat.seatNumber,
coach: ps.seat.coach.number,
seat: ps.seat?.seatNumber,
coach: ps.seat?.coach?.number,
leg: ps.leg || 1,
scheduleId: ps.scheduleId || booking.scheduleId,
})),

View File

@@ -21,6 +21,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.0",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, Ticket } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -35,6 +35,8 @@ function BookingsPageContent() {
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
const [generateTicketForm, setGenerateTicketForm] = useState({ paymentReference: '', paymentMethod: '', notes: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -63,6 +65,19 @@ function BookingsPageContent() {
}),
});
const forceConfirmMutation = useMutation({
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
bookingsApi.forceConfirm(bookingId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Payment confirmed and ticket generated successfully');
setTimeout(() => setSuccessMessage(''), 4000);
setSelectedBooking(null);
setGenerateTicketBooking(null);
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
},
});
const deleteMutation = useMutation({
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
onSuccess: () => {
@@ -258,6 +273,7 @@ function BookingsPageContent() {
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
@@ -448,6 +464,25 @@ function BookingsPageContent() {
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
</p>
<ActionButton
variant="secondary"
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
</ActionButton>
{forceConfirmMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</p>
)}
</div>
)}
</section>
{/* Seats / Passengers */}
@@ -517,6 +552,93 @@ function BookingsPageContent() {
onCascadeChange={(checked) => setDeleteCascadeChecked(checked)}
/>
{/* Generate Ticket Modal */}
<Modal
isOpen={!!generateTicketBooking}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
title="Generate Ticket"
size="md"
>
{generateTicketBooking && (
<div className="space-y-4">
<div className="-mx-6 -mt-4 mb-4 px-6 py-4 bg-muted/40 border-b border-muted">
<p className="text-xs text-muted-foreground">Booking</p>
<p className="font-mono font-bold text-lg">{generateTicketBooking.bookingRef}</p>
<p className="text-sm text-muted-foreground">
{generateTicketBooking.schedule?.originStation?.name} {generateTicketBooking.schedule?.destinationStation?.name}
</p>
</div>
<div>
<label className="label">Payment Reference / Transaction ID</label>
<input
type="text"
className="input"
placeholder="e.g. TXN-123456 or vendor receipt number"
value={generateTicketForm.paymentReference}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentReference: e.target.value }))}
/>
</div>
<div>
<label className="label">Payment Method</label>
<select
className="input"
value={generateTicketForm.paymentMethod}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentMethod: e.target.value }))}
>
<option value="">Select method</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="WAAFI">Waafi</option>
<option value="DMONEY">dMoney</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
</select>
</div>
<div>
<label className="label">Notes <span className="text-muted-foreground font-normal">(optional)</span></label>
<textarea
className="input min-h-[72px] resize-none"
placeholder="e.g. Customer paid at counter, receipt #123"
value={generateTicketForm.notes}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, notes: e.target.value }))}
/>
</div>
{forceConfirmMutation.isError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
>
Cancel
</ActionButton>
<ActionButton
onClick={() => forceConfirmMutation.mutate({
bookingId: generateTicketBooking.id,
data: {
paymentReference: generateTicketForm.paymentReference || undefined,
paymentMethod: generateTicketForm.paymentMethod || undefined,
notes: generateTicketForm.notes || undefined,
},
})}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
</ActionButton>
</div>
</div>
)}
</Modal>
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">

View File

@@ -2,13 +2,16 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { routesApi } from '@/lib/api/routes';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
export default function SeatsPage() {
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [selectedRoute, setSelectedRoute] = useState('');
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
@@ -39,6 +42,30 @@ export default function SeatsPage() {
queryFn: () => fleetApi.getCoaches(),
});
const { data: routesData } = useQuery({
queryKey: ['routes'],
queryFn: () => routesApi.getAll(),
});
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
queryKey: ['routeCoaches', selectedRoute],
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
if (!template?.length) return [];
const fullCoaches = await Promise.all(
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
);
return fullCoaches.map((coach: any, i: number) => ({
...coach,
coachNumber: coach.number,
positionNumber: template[i].positionNumber,
seatArrangement: coach.arrangement,
}));
},
enabled: !!selectedRoute,
});
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => {
@@ -89,7 +116,8 @@ export default function SeatsPage() {
});
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
const routes = routesData?.items || routesData?.data || [];
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
const blockCoachMutation = useMutation({
mutationFn: async ({ coachId, reason }: any) => {
@@ -464,6 +492,9 @@ export default function SeatsPage() {
return seqA - seqB;
});
const activeSelection = activeTab === 'schedule' ? selectedSchedule : selectedRoute;
const isLoadingData = activeTab === 'schedule' ? isLoading : routeCoachesLoading;
return (
<div className="space-y-6">
<div>
@@ -471,31 +502,62 @@ export default function SeatsPage() {
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
</div>
{!selectedSchedule ? (
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg w-fit">
<button
onClick={() => setActiveTab('route')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'route'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
By Route
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'schedule'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
By Schedule
</button>
</div>
{!activeSelection ? (
<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 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}>
{date} - {routeName}
</option>
);
})}
</select>
{activeTab === 'schedule' ? (
<>
<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 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}>{date} - {routeName}</option>;
})}
</select>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
<div className="text-center py-12 text-muted-foreground mt-8">
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
<p>Select a {activeTab === 'schedule' ? 'schedule' : 'route'} to view seat map</p>
</div>
</div>
) : isLoading ? (
) : isLoadingData ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
@@ -503,51 +565,63 @@ export default function SeatsPage() {
) : coachesWithSeats.length === 0 ? (
<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>
{activeTab === 'schedule' ? (
<>
<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>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
</div>
<div className="card text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
<p>No coaches with seats found for this {activeTab === 'schedule' ? 'schedule' : 'route'}</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card h-fit sticky top-6 space-y-6">
<div>
<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>
{activeTab === 'schedule' ? (
<>
<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>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
</div>
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">

View File

@@ -1,66 +1,362 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { supportApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { Passenger } from '@edr/types';
import { Headset, Search, Send, User } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useMarkRead,
useMessages,
useSendMessage,
useSetStatus,
} from '@/features/support/useSupport';
import { useSupportSocket } from '@/features/support/useSupportSocket';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
const FILTERS = ['ALL', 'OPEN', 'RESOLVED', 'CLOSED'] as const;
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export default function SupportPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [status, setStatus] = useState<(typeof FILTERS)[number]>('ALL');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['support', filters],
queryFn: () => supportApi.getConversations(filters),
});
const { data, isLoading } = useConversations(
status === 'ALL' ? { search } : { status, search },
);
const items = data?.items ?? [];
const columns = [
{ key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' },
{ key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' },
{ key: 'status', label: 'Status', render: (conv: any) => <Badge variant="status" status={conv.status}>{conv.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) },
];
useSupportSocket(true);
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-lg"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={20} />
</span>
<div>
<h1 className="text-2xl font-bold text-foreground">Support Center</h1>
<p className="text-muted-foreground">Manage customer support conversations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="OPEN">Open</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="RESOLVED">Resolved</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<p className="text-sm text-muted-foreground">
Shared inbox respond to passenger requests in real time
</p>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No support center found"
/>
<div className="flex h-[calc(100vh-220px)] overflow-hidden rounded-xl border border-border bg-card">
{/* Conversation list */}
<div className="flex w-[340px] shrink-0 flex-col border-r border-border">
<div className="space-y-2 border-b border-border p-3">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-2.5 text-muted-foreground"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search subject or passenger"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div className="flex gap-1">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatus(f)}
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium capitalize transition ${
status === f
? 'text-white'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
style={status === f ? { background: GREEN } : undefined}
>
{f.toLowerCase()}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No conversations.
</div>
) : (
items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))
)}
</div>
</div>
{/* Thread */}
<div className="min-w-0 flex-1">
{selected ? (
<ConversationThread conversation={selected} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<span
className="flex h-14 w-14 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={28} />
</span>
<p className="text-sm">Select a conversation to start replying.</p>
</div>
)}
</div>
</div>
</div>
);
}
function InboxRow({
c,
active,
onClick,
}: {
c: ConversationDto;
active: boolean;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-border px-4 py-3 text-left transition hover:bg-muted/50 ${
active ? 'bg-muted/70' : ''
}`}
style={active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread ? 'font-bold text-foreground' : 'font-semibold text-foreground/90'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTime(c.lastMessageAt)}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.passengerName || 'Passenger'}
</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">
{c.lastMessageSender === 'AGENT' ? 'You: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function ConversationThread({ conversation }: { conversation: ConversationDto }) {
const { data: messages, isLoading } = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const setStatus = useSetStatus();
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
markRead.mutate(conversation.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length, conversation.id]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
const changeStatus = (status: string) =>
setStatus.mutate({ id: conversation.id, status });
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b border-border p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-foreground">
{conversation.subject || 'Conversation'}
</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[conversation.status] ?? ''
}`}
>
{conversation.status.toLowerCase()}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{conversation.passengerName || 'Passenger'}
{conversation.guestId ? ' · Guest' : ''}
{conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''}
{conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''}
</p>
</div>
<div className="flex shrink-0 gap-2">
{conversation.status !== 'OPEN' && (
<button
onClick={() => changeStatus('OPEN')}
className="rounded-md px-3 py-1.5 text-xs font-medium text-white"
style={{ background: GREEN }}
>
Reopen
</button>
)}
{conversation.status === 'OPEN' && (
<button
onClick={() => changeStatus('RESOLVED')}
className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Resolve
</button>
)}
{conversation.status !== 'CLOSED' && (
<button
onClick={() => changeStatus('CLOSED')}
className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/70"
>
Close
</button>
)}
</div>
</div>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : (
(messages ?? []).map((m) => <AgentBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-border p-3">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
className="max-h-28 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-emerald-500"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
}
function AgentBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'AGENT';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'} items-end gap-2`}>
{!mine && (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User size={14} />
</span>
)}
<div className="max-w-[70%]">
<p
className={`mb-0.5 text-xs text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{mine ? m.authorName || 'You' : m.authorName || 'Passenger'}
</p>
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-muted text-foreground'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}

View File

@@ -104,7 +104,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Customer Services',
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
// { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
]
},

View File

@@ -0,0 +1,36 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface ListParams {
status?: string;
search?: string;
}
/** Passenger backoffice (agent) support-chat REST calls (client unwraps envelope). */
export const supportApi = {
listConversations: (params: ListParams = {}) =>
apiClient.get<ListResult>('/support/agent/conversations', { params }),
listMessages: (id: string) =>
apiClient.get<MessageDto[]>(`/support/agent/conversations/${id}/messages`),
sendMessage: (id: string, text: string) =>
apiClient.post<MessageDto>(
`/support/agent/conversations/${id}/messages`,
{ text },
),
setStatus: (id: string, status: string) =>
apiClient.patch<ConversationDto>(
`/support/agent/conversations/${id}/status`,
{ status },
),
markRead: (id: string) =>
apiClient.post<{ unreadCount: number }>(
`/support/agent/conversations/${id}/read`,
),
unreadCount: () =>
apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type ListParams } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(params: ListParams = {}) {
return useQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations(params),
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useSetStatus() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
supportApi.setStatus(id, status),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in agent to live support pushes for the whole shared
* inbox. Any new message / conversation change refreshes the thread, the inbox
* list, and the unread badge; `onMessage` fires for optional toasts.
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
const token = localStorage.getItem('auth_token');
if (!token) return;
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth: { token },
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -28,4 +28,7 @@ export const bookingsApi = {
cancel: (id: string, reason?: string) => {
return apiClient.post<Booking>(`/bookings/${id}/cancel`, { reason });
},
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
};

View File

@@ -44,6 +44,8 @@ export const bookingsApi = {
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
};
// Passengers API
@@ -115,6 +117,7 @@ export const fleetApi = {
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
getCoach: (id: string) => apiClient.get<any>(`/fleet/coaches/${id}`),
};
// Schedules API

View File

@@ -28,6 +28,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"socket.io-client": "^4.8.3",
"zod": "^3.22.4",
"zustand": "^5.0.0"
},

View File

@@ -0,0 +1,37 @@
// Server-side proxy for POST /seats/release on the passenger API.
//
// The browser calls THIS route (same-origin, /api/seats/release-hold) instead of the
// backend's public release endpoint directly. Route handlers run on the Next.js server, not
// in the browser, so the actual backend call — and its URL — never appears in client-side
// JS or network requests a user could copy and script against other travellers' holds.
// Keeping this indirection is the whole point: it doesn't add cryptographic protection (the
// backend endpoint is still public), it just keeps the release capability out of the
// browser's reach so it can't be trivially discovered and abused from client code.
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
export async function POST(request: Request) {
let holdId: string | undefined;
try {
({ holdId } = await request.json());
} catch {
return Response.json({ message: "Invalid JSON body" }, { status: 400 });
}
if (!holdId || typeof holdId !== "string") {
return Response.json({ message: "holdId is required" }, { status: 400 });
}
try {
const backendResponse = await fetch(`${API_URL}/seats/release`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ holdId }),
});
const data = await backendResponse.json().catch(() => null);
return Response.json(data, { status: backendResponse.status });
} catch {
return Response.json({ message: "Failed to reach booking service" }, { status: 502 });
}
}

View File

@@ -102,7 +102,7 @@ export default function ConfirmationPage() {
const { packageTierPriceMinor } = useBookingStore.getState();
const isPackageBooking = packageTierPriceMinor != null;
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const pkgMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgMultiplier = isPackageBooking ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare;

View File

@@ -5,28 +5,47 @@ import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import {
Clock,
Users,
CheckCircle2,
import {
Clock,
Users,
CheckCircle2,
AlertCircle,
Download,
Share2,
Copy,
Check,
CreditCard,
Wallet
Wallet,
Smartphone,
Loader2,
ChevronLeft,
} from 'lucide-react';
import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { formatFare } from '@/utils/fare-utils';
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import QRCode from 'qrcode.react';
// Same convention as /booking/payment — payment methods are ETB-settled by default;
// a method only needs a currency conversion when its own currency differs.
const displayCurrency = 'ETB' as const;
const getIconForMethod = (methodType: string) => {
if (methodType.includes('CARD')) return CreditCard;
if (methodType.includes('WALLET')) return Wallet;
return Smartphone;
};
function BookingDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr');
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
// Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type`
// (used both for lookup and to decide provider-specific redirect handling), not the id.
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
@@ -42,40 +61,84 @@ function BookingDetailContent() {
retry: 1,
});
const { data: paymentMethods } = useQuery({
const { data: paymentMethods } = useQuery<any[]>({
queryKey: ['payment-methods'],
queryFn: () => apiClient.get('/payments/methods'),
enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT',
});
const paymentMutation = useMutation({
mutationFn: async (paymentData: any) => {
const response = await apiClient.post('/payments/intent', paymentData);
const selectedPaymentMethod = (paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
// when the selected method actually settles in a different currency than ETB.
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', booking?.id, amountCurrency],
queryFn: async () => {
const url = `/payments/booking-amount?bookingId=${booking?.id}&currency=${amountCurrency}`;
const response: any = await apiClient.get(url);
return response;
},
onSuccess: async (data: any) => {
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
paymentIntentId: data.id,
paymentMethod: selectedPaymentMethod,
enabled: !!booking?.id && isConversionNeeded,
});
const totalAmountDisplay = isConversionNeeded
? (bookingAmountData != null ? bookingAmountData.amount : null)
: ((booking?.totalMinor ?? 0) / 100);
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
const awaitingAmount = isConversionNeeded && loadingAmount && totalAmountDisplay === null;
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
return await apiClient.post('/payments/initiate', {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
platform: 'web',
});
refetch();
},
onSuccess: async (data: any) => {
setPaymentError(null);
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
window.location.href = data.clientAction.url;
return;
}
// No redirect needed (e.g. WALLET) — the marker set in handlePayment is now moot.
consumeManageBookingPaymentReturn();
await refetch();
},
onError: (error: any) => {
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
setPaymentError(
error?.response?.data?.message ||
error?.message ||
'Payment failed. Please try again.',
);
},
});
const handlePayment = () => {
if (!selectedPaymentMethod) {
alert('Please select a payment method');
if (!selectedMethod || !booking?.id) {
setPaymentError('Please select a payment method');
return;
}
if (!selectedPaymentMethod) {
setPaymentError('Invalid payment method selected');
return;
}
setPaymentError(null);
// Flag this as a Manage Booking payment so the gateway's success/failure return page
// sends the user back here instead of the new-booking confirmation flow.
markManageBookingPaymentReturn(booking.bookingRef);
paymentMutation.mutate({
bookingId: booking?.id,
amount: booking?.totalMinor || 0,
currency: booking?.currency || 'ETB',
paymentMethodId: selectedPaymentMethod,
bookingId: booking.id,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
});
};
@@ -163,38 +226,163 @@ function BookingDetailContent() {
);
};
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Complete Payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Booking Reference: <span className="font-mono font-semibold">{booking.bookingRef}</span>
</p>
</div>
<StatusBadge />
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
// outbound, leg 2 = return) — /booking/payment's fare breakdown, by contrast, shows one
// combined row per passenger with an Outbound/Return sub-split. Group leg rows back
// together here so both pages present the same per-passenger total, not a doubled list
// of half-fare rows.
const isRoundTripBooking = booking.bookingType === 'ROUND_TRIP';
const farePassengers = (() => {
const rows: any[] = booking.passengers || [];
if (!isRoundTripBooking) {
return rows.map((p) => ({ fullName: p.fullName, category: p.category, fareMinor: p.fareMinor ?? 0 }));
}
const grouped = new Map<string, { fullName: string; category: string; outboundFareMinor: number; returnFareMinor: number }>();
rows.forEach((p) => {
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundFareMinor: 0, returnFareMinor: 0 };
if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0;
else entry.outboundFareMinor = p.fareMinor ?? 0;
grouped.set(key, entry);
});
return Array.from(grouped.values()).map((p) => ({
fullName: p.fullName,
category: p.category,
fareMinor: p.outboundFareMinor + p.returnFareMinor,
outboundFareMinor: p.outboundFareMinor,
returnFareMinor: p.returnFareMinor,
}));
})();
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
// passenger, Total with a loading spinner while a currency conversion is in flight, and
// a note confirming what will actually be charged once a payment method is selected.
const OrderSummary = () => (
<div className="card space-y-4">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Order summary
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
Ref: <span className="font-bold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
</span>
</h2>
<div className="space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{farePassengers.map((passenger: any, idx: number) => {
const isChildPassenger = passenger.category === 'CHILD';
const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0;
return (
<div key={idx} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<div className="flex justify-between mb-0.5">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{passenger.fullName || `Passenger ${idx + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${isFreeChild ? 'text-green-600' : 'text-blue-600'}`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span>
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatFare(passenger.fareMinor ?? 0, displayCurrency)}
</span>
</div>
{booking.createdAt && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-800 dark:text-amber-300">
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
</span>
{isRoundTripBooking && !isFreeChild && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>{formatFare(passenger.outboundFareMinor ?? 0, displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>{formatFare(passenger.returnFareMinor ?? 0, displayCurrency)}</span>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
)}
</span>
</div>
{selectedPaymentMethod && !awaitingAmount && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName}
</p>
)}
</div>
{/* Pay + back buttons — desktop sidebar only */}
<div className="hidden lg:flex flex-col gap-2 pt-1">
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs"> {paymentError}</p>
)}
<button
onClick={handlePayment}
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-1">
🔒 Secure & encrypted payment
</p>
</div>
</div>
);
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
<div className="card mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Booking Reference: <span className="font-mono font-semibold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
</p>
{booking.createdAt && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
</p>
)}
</div>
<StatusBadge />
</div>
{/* Two-column grid — matches /booking/payment's layout */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
{/* Left column — trip/payment method (2/3 width) */}
<div className="lg:col-span-2 space-y-4">
<div className="card">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Trip Summary</h2>
<div className="flex items-center gap-2 mb-4">
@@ -302,43 +490,40 @@ function BookingDetailContent() {
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select Payment Method</h2>
<div className="card">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select payment method</h2>
{paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{paymentMethods.map((method: any) => (
<button
key={method.id}
onClick={() => setSelectedPaymentMethod(method.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
selectedPaymentMethod === method.id
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedPaymentMethod === method.id
? 'bg-primary/20 dark:bg-primary/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
{method.type === 'WALLET' ? (
<Wallet className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
) : (
<CreditCard className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
<div className="space-y-3">
{paymentMethods.map((method: any) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
disabled={paymentMutation.isPending || method.enabled === false}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50'
} ${paymentMutation.isPending || method.enabled === false ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Icon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
</div>
{isSelected && (
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
)}
</div>
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-white">{method.displayName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{method.currency}</div>
</div>
{selectedPaymentMethod === method.id && (
<Check className="w-5 h-5 text-primary" />
)}
</div>
</button>
))}
</button>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -347,39 +532,60 @@ function BookingDetailContent() {
)}
</div>
<button
onClick={handlePayment}
disabled={!selectedPaymentMethod || paymentMutation.isPending}
className="w-full py-4 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-lg rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{paymentMutation.isPending ? 'Processing Payment...' : `Pay ${booking.displayCurrency} ${((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}`}
</button>
</div>
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 sticky top-6">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Order Summary</h2>
<div className="space-y-3 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''})</span>
<span className="font-semibold text-gray-900 dark:text-white">
{booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex justify-between">
<span className="text-lg font-bold text-gray-900 dark:text-white">Total</span>
<span className="text-2xl font-bold text-primary">
{booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
{/* Order summary inline — mobile only */}
<div className="lg:hidden">
<OrderSummary />
</div>
</div>
</div>
{/* Right column — sticky order summary (desktop only) */}
<div className="hidden lg:block">
<div className="sticky top-6">
<OrderSummary />
</div>
</div>
</div>{/* end grid */}
</div>
</div>
{/* Mobile sticky bottom bar */}
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
)}
</span>
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2"> {paymentError}</p>
)}
<div className="flex gap-3">
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handlePayment}
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
</div>
</div>
</div>

View File

@@ -568,9 +568,8 @@ function createFormSchema(adultCount: number) {
// Contact fields are only collected from — and validated against — adults.
// Children's phone/email are inherited from the primary adult, not user-entered.
if (isAdult) {
if (!p.email || p.email.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required', path: ['passengers', i, 'email'] });
} else {
// Email is optional — only validate its format when the user actually provides one.
if (p.email && p.email.trim().length > 0) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(p.email)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] });
@@ -1191,7 +1190,7 @@ export default function PassengersPage() {
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email (optional)</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
@@ -1280,7 +1279,7 @@ export default function PassengersPage() {
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email (optional)</label>
<input
type="email"
{...register(`passengers.${index}.email`)}

View File

@@ -2,45 +2,33 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Suspense } from 'react';
function DmoneySuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// D-Money callback query params (mirrors Telebirr)
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: orderid || trxRef,
paymentMethod: 'DMONEY',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -61,7 +49,7 @@ function DmoneySuccessContent() {
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your D-Money payment was received.</p>
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
@@ -71,8 +59,8 @@ function DmoneySuccessContent() {
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
</>
)}
</div>

View File

@@ -48,9 +48,16 @@ export default function PaymentPage() {
},
});
// Fetch actual booking amount from API when a payment method is selected
const amountCurrency = selectedMethodCurrency || displayCurrency;
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
// A payment method only needs a currency conversion when its own currency differs from
// the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
// total already shown on the review page is exact and there's nothing to convert.
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
// Fetch the converted booking amount from the booking-amount-changer API whenever a
// currency-specific payment method is selected.
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', bookingId, amountCurrency],
queryFn: async () => {
@@ -58,7 +65,7 @@ export default function PaymentPage() {
const response: any = await apiClient.get(url);
return response;
},
enabled: !!bookingId,
enabled: !!bookingId && isConversionNeeded,
});
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
@@ -71,32 +78,39 @@ export default function PaymentPage() {
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
: 0;
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
// they were computed and shown to the user on the review page, so the Total here must match.
// The API booking-amount is used only as the charge amount sent to the payment provider.
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
// in the booking's default currency (ETB) — they were computed and shown to the user on
// the review page. But once a payment method with its own currency is selected (e.g.
// Waafi/USD), the converted amount from the booking-amount API takes over so the user
// sees the actual amount they'll be charged in that currency.
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null);
const totalAmount = bookingAmountData != null
? Math.round(bookingAmountData.amount * 100)
: (reviewedTotal ?? 0);
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
const totalAmountDisplay = isConversionNeeded
? (bookingAmountData != null ? bookingAmountData.amount : null)
: (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
const totalAmount = isConversionNeeded
? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
: (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
// Show loading spinner only when the API hasn't responded AND we have no review-page
// total to fall back on — once reviewedTotalMinor is set the button is always enabled.
const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null;
// Show loading spinner while the converted amount is still in flight for a
// currency-specific method; ETB methods always have the reviewed total instantly.
const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
useEffect(() => {
// Always store the reviewed total (minor, ETB) as the paid amount — it's what was
// shown to the user and matches the fare breakdown. The API amount is only used as
// the charge sent to the provider (may differ due to currency conversion).
if (reviewedTotal != null) {
// Once a currency-specific payment method's converted amount has loaded, that's the
// real charge amount and currency — store it as the paid amount. Otherwise fall back
// to the reviewed ETB total shown on the review page.
if (isConversionNeeded && bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(Math.round(bookingAmountData.amount * 100));
} else if (reviewedTotal != null) {
setCurrency('ETB');
setPaidAmount(reviewedTotal);
} else if (bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(Math.round(bookingAmountData.amount * 100));
}
}, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
}, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -145,9 +159,6 @@ export default function PaymentPage() {
setIsProcessing(true);
setPaymentError(null);
// Find the selected payment method to get its ID
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
setIsProcessing(false);
@@ -320,6 +331,11 @@ export default function PaymentPage() {
)}
</span>
</div>
{selectedPaymentMethod && !awaitingAmount && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName}
</p>
)}
</div>
{/* Pay + back buttons — desktop sidebar only */}

View File

@@ -2,13 +2,18 @@
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { useEffect, useState, Suspense } from 'react';
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function TelebirrFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
// A Manage Booking payment (paying for an already-existing booking) has no in-progress
// booking-store session to go "back to review" from — send it back to that booking's
// detail view instead, where the user can pick a different payment method.
const [backTarget, setBackTarget] = useState('/booking/review');
const merchantOrderId = searchParams.get('merchantOrderId') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
@@ -16,6 +21,10 @@ function TelebirrFailureContent() {
const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.';
useEffect(() => {
const manageBookingRef = consumeManageBookingPaymentReturn();
if (manageBookingRef) {
setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
}
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -30,10 +39,10 @@ function TelebirrFailureContent() {
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/review')}
<button onClick={() => router.push(backTarget)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review
Back
</button>
</div>
</div>

View File

@@ -2,45 +2,33 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Suspense } from 'react';
function TelebirrSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Telebirr callback query params
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: orderid || trxRef,
paymentMethod: 'TELEBIRR',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -61,7 +49,7 @@ function TelebirrSuccessContent() {
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
@@ -71,8 +59,8 @@ function TelebirrSuccessContent() {
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
</>
)}
</div>

View File

@@ -2,13 +2,18 @@
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { useEffect, useState, Suspense } from 'react';
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function WaafiFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
// A Manage Booking payment (paying for an already-existing booking) has no in-progress
// booking-store session to go "back to review" from — send it back to that booking's
// detail view instead, where the user can pick a different payment method.
const [backTarget, setBackTarget] = useState('/booking/review');
const referenceId = searchParams.get('referenceId') || '';
const responseCode = searchParams.get('responseCode') || '';
@@ -17,6 +22,10 @@ function WaafiFailureContent() {
const state = searchParams.get('state') || '';
useEffect(() => {
const manageBookingRef = consumeManageBookingPaymentReturn();
if (manageBookingRef) {
setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
}
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -33,10 +42,10 @@ function WaafiFailureContent() {
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
)}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/review')}
<button onClick={() => router.push(backTarget)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review
Back
</button>
</div>
</div>

View File

@@ -2,57 +2,32 @@
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
function WaafiSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
// Waafi callback query params
const accountNo = searchParams.get('accountNo') || '';
const currency = searchParams.get('currency') || '';
const referenceId = searchParams.get('referenceId') || '';
const state = searchParams.get('state') || '';
const referenceId = searchParams.get('referenceId') || '';
const transactionId = searchParams.get('transactionId') || '';
const txAmount = searchParams.get('txAmount') || '';
const timestamp = searchParams.get('timestamp') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const txAmount = searchParams.get('txAmount') || '';
const currency = searchParams.get('currency') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: referenceId || transactionId,
paymentMethod: 'WAAFI',
transactionDetails: {
transactionId,
accountNo,
amount: txAmount,
currency,
state,
timestamp,
},
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -76,7 +51,7 @@ function WaafiSuccessContent() {
{txAmount && currency && (
<p className="text-xs text-gray-400">Amount: {txAmount} {currency}</p>
)}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
</div>

View File

@@ -143,17 +143,18 @@ export default function ResultsPage() {
}
}
// 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;
// One-way searches that come back with an empty outbound list may still include
// date-shifted alternatives from the API — surface those instead of a dead end.
const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : [];
// Alternatives are surfaced whenever a leg returns no exact-date results.
const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : [];
const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : [];
const requestedDate: string = (results && results.requestedDate) || searchData.date;
const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || '';
const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
// Round-trip: show results view if either leg has exact results OR alternatives.
// One-way: need at least one outbound result.
const hasResults = isRoundTrip
? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0)
: outboundSchedules.length > 0;
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
@@ -687,8 +688,28 @@ export default function ResultsPage() {
}
if (!hasResults) {
// ONE_WAY search with an explicit empty outbound list — surface any date-shifted
// alternatives the API suggests instead of a dead-end "no trains found" screen.
const isRoundTripNoResults = isRoundTrip && !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && alternativeOutbound.length === 0 && alternativeInbound.length === 0;
if (isRoundTripNoResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains for your round trip. Try adjusting your dates or route.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Modify search</button>
</div>
</div>
</div>
</div>
);
}
if (isOneWayNoOutbound) {
const requestedDateLabel = requestedDate
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
@@ -843,6 +864,26 @@ export default function ResultsPage() {
<div className="space-y-4">
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
</div>
{outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Outbound Options</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))}
</div>
</div>
)}
</div>
) : (
<div>
@@ -891,6 +932,26 @@ export default function ResultsPage() {
<div className="space-y-4" id="inbound-section">
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
</div>
{inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Return Options</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))}
</div>
</div>
)}
</div>
)
) : (

View File

@@ -47,7 +47,7 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() {
const router = useRouter();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, bookingId, pnr, bookingHoldId, bookingReturnHoldId, reviewedTotalMinor, setBookingId, setPNR, setBookingHoldReference, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
@@ -149,6 +149,31 @@ export default function ReviewPage() {
fetchSeatDetails();
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
const pkgChildFare = pkgAdultFare;
const isPackageChild = (index: number) =>
isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]);
const isPkgFreeChild = (index: number) => {
if (!isPackageBooking) return false;
if (!isPackageChild(index)) return false;
const childIndex = index - adultPassengerCount;
return childIndex < adultPassengerCount;
};
const getPassengerSeatFare = (p: any): number | null => {
if (isRoundTrip) {
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
}
if (p.seatFareMinor == null) return null;
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
};
const createBookingMutation = useMutation({
mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
@@ -159,6 +184,11 @@ export default function ReviewPage() {
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
setBookingId(bookingIdValue);
setPNR(pnrValue);
// Remember which hold(s) this booking was created from, so if the user comes back
// here (e.g. hitting "back" from the payment gateway) with the same hold still in
// the store, we can detect it's the same booking and reuse it instead of creating
// another one.
setBookingHoldReference(seatHold?.holdId || null, seatHold?.returnHoldId || null);
const totalAmount = data.totalMinor || data.totalAmount || 0;
setTimeout(() => {
if (totalAmount > 0) {
@@ -177,7 +207,21 @@ export default function ReviewPage() {
const handleConfirm = async () => {
try {
const { searchCriteria } = useBookingStore.getState();
// A booking already exists for the exact hold(s) currently in the store — e.g. the
// user was sent to the payment gateway and hit "back". Reuse it instead of creating
// a duplicate booking; just resume the payment step.
const sameHoldAsExistingBooking =
!!bookingId &&
!!pnr &&
!!seatHold?.holdId &&
seatHold.holdId === bookingHoldId &&
(isRoundTrip ? (seatHold.returnHoldId || null) === bookingReturnHoldId : true);
if (sameHoldAsExistingBooking) {
router.push((reviewedTotalMinor ?? 0) > 0 ? '/booking/payment' : '/booking/confirmation');
return;
}
if (!seatHold?.holdId) {
alert('Please select seats before continuing.');
router.push('/booking/seats');
@@ -393,8 +437,9 @@ export default function ReviewPage() {
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild };
});
@@ -423,7 +468,7 @@ export default function ReviewPage() {
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
// Package bookings use the stored tier price — no fare calculation needed
if (packageTierPriceMinor !== null) return;
if (isPackageBooking) return;
try {
const seatClasses: any[] = await apiClient.get('/seat-classes');
@@ -433,9 +478,6 @@ export default function ReviewPage() {
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
if (!fallbackSeatClassId) return;
// Bed coaches price Upper/Middle/Lower as separate classes, so a passenger's own
// assigned berth (captured on the seats page) must resolve its own seatClassId here
// — a single shared class can't correctly price passengers in different berths.
const resolveSeatClassId = (p: any): string => {
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
if (!bedPosition) return fallbackSeatClassId;
@@ -465,7 +507,7 @@ export default function ReviewPage() {
setFareBreakdown(result);
} catch (err) {
}
}, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -474,37 +516,17 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
const isPackageBooking = packageTierPriceMinor !== null;
// packageTierPriceMinor is the per-adult fare for ONE leg.
// Round-trip packages multiply by 2.
// First child per adult travels FREE; additional children pay full adult fare.
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount);
// Paid children pay full adult fare
const pkgChildFare = pkgAdultFare; // full fare for paid children
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
const isPackageChild = (index: number) =>
isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]);
// Per-seat fare captured on the seats page (bed-position-aware, computed locally from
// the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the
// backend /search/fare-breakdown call whose seatClassId matching for bed positions can't
// be verified here. Prefer it whenever the passenger actually has an assigned seat.
const getPassengerSeatFare = (p: any): number | null => {
if (isRoundTrip) {
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
}
return p.seatFareMinor ?? null;
};
const total = isPackageBooking
? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare
? passengers.reduce((sum, p, i) => {
const isChild_ = isPackageChild(i);
const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
if (isFreeChild) return sum;
const seatFare = getPassengerSeatFare(p);
const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
return sum + (seatFare ?? pkgFallback);
}, 0)
: passengers.reduce((sum, p, i) => {
const isChildPassenger = isChild(p);
const line = fareBreakdown?.passengers?.[i];
@@ -517,16 +539,6 @@ export default function ReviewPage() {
// Keep computedTotal in sync so handleConfirm can persist it to the store
useEffect(() => { setComputedTotal(total); }, [total]);
// For package bookings, determine if a child is free (first per adult) or paid.
// Children are ordered after adults in the passengers array (set on package detail page).
const isPkgFreeChild = (index: number) => {
if (!isPackageBooking) return false;
if (!isPackageChild(index)) return false;
// childIndex = position among children (0-based)
const childIndex = index - adultPassengerCount;
return childIndex < adultPassengerCount; // first adultCount children are free
};
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
<div className="card space-y-3">
@@ -541,7 +553,7 @@ export default function ReviewPage() {
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
const passengerTotal = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return (
@@ -618,9 +630,6 @@ export default function ReviewPage() {
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
<div className="w-2 h-2 bg-primary rounded-full" />
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Outbound Journey</h2>
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
{outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
@@ -695,9 +704,6 @@ export default function ReviewPage() {
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Return Journey</h2>
<span className="ml-auto text-xs px-2.5 py-1 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-semibold">
{inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}

View File

@@ -164,6 +164,7 @@ export default function SeatsPage() {
outboundSchedule,
inboundSchedule,
passengers,
seatHold,
setSeatHold,
setPassengers,
setSelectedSchedule,
@@ -172,6 +173,12 @@ export default function SeatsPage() {
searchCriteria,
bookingId,
packageName,
packageId,
priceTierId,
packageTierPriceMinor,
packageDepartureStationId,
packageDepartureStationName,
setPackageContext,
} = useBookingStore();
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
@@ -197,9 +204,16 @@ export default function SeatsPage() {
type: "info" as "warning" | "error" | "success" | "info",
onConfirm: undefined as (() => void) | undefined,
showCancel: false,
confirmText: "OK",
});
const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
// Mobile summary bottom-sheet starts collapsed to a slim bar (badge + Continue button) so
// it doesn't cover the seat map — the full passenger list/progress bar only shows once the
// user explicitly expands it.
const [mobileSummaryExpanded, setMobileSummaryExpanded] = useState(false);
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
const isPackageBooking = !!packageName;
const currentSchedule =
isRoundTrip && currentJourneyType === "inbound"
? inboundSchedule
@@ -211,6 +225,29 @@ export default function SeatsPage() {
? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND"
: "ONE_WAY";
// The hold id/expiry that already covers the CURRENT leg, if any — used to avoid
// creating a second, redundant hold when the user navigates back to this page (e.g.
// browser back button, or bouncing between passengers/seats) without actually changing
// their seat pick.
const currentLegHoldId = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnHoldId : seatHold?.holdId;
const currentLegHoldExpiresAt = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnExpiresAt : seatHold?.expiresAt;
const isCurrentLegHoldValid =
!!currentLegHoldId && (!currentLegHoldExpiresAt || new Date(currentLegHoldExpiresAt).getTime() > Date.now());
// The seat id(s) already recorded against each passenger for the current leg from a
// previous pass through this page (persisted in the store) — the counterpart of the
// hold above, so we can tell whether the current on-screen selection actually differs
// from what's already held.
const previouslyHeldSeatIds = useMemo(
() =>
passengers.map((p) =>
isRoundTrip
? (currentJourneyType === "inbound" ? (p as any).inboundSeatId : (p as any).outboundSeatId)
: (p as any).seatId,
),
[passengers, isRoundTrip, currentJourneyType],
);
// Baseline fare for each leg as it was when this page first loaded — i.e. whatever was
// picked on the results page ("starting from" price). Captured once and never
// overwritten, so a later coach-type switch (or just picking a pricier berth) can still
@@ -267,6 +304,35 @@ export default function SeatsPage() {
[passengers, seatEligibility],
);
// If this leg already has a valid (unexpired) hold from a previous pass through this
// page — e.g. the user hit "back" from a later step — restore the seat(s) that hold
// actually covers instead of leaving the seat map blank and letting them pick (and
// hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a
// deliberate deselect/re-pick afterwards.
const restoredLegRef = useRef<string | null>(null);
// Indices whose current passengerSeatMap entry came from the restoration above, not a
// deliberate click this visit. A passenger's next click should be treated as their
// first real pick (no fare-change modal) even though the map already has an entry for
// them — only a click AFTER that (replacing their own real pick) is an actual change.
const restoredIndicesRef = useRef<Set<number>>(new Set());
useEffect(() => {
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
if (restoredLegRef.current === legKey) return;
restoredLegRef.current = legKey;
if (!isCurrentLegHoldValid) return;
const restored: Record<number, string> = {};
seatEligibleIndices.forEach((i) => {
const seatId = previouslyHeldSeatIds[i];
if (seatId) restored[i] = seatId;
});
if (Object.keys(restored).length > 0) {
setPassengerSeatMap(restored);
restoredIndicesRef.current = new Set(Object.keys(restored).map(Number));
}
}, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]);
const {
data: seatMapData,
isLoading,
@@ -306,8 +372,10 @@ export default function SeatsPage() {
return raw
.map((c: any, idx: number) => ({
id: c.id || c.coachId || String(idx),
coachId: c.coachId || c.id || null,
label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`,
type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""),
coachTypeName: String(c.coachTypeName || c.type || c.coachType || c.category || ""),
typeName: c.coachTypeName || c.coachType || c.category || c.type || "",
coachTypeId: c.coachTypeId ?? c.typeId ?? null,
remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null,
@@ -316,6 +384,22 @@ export default function SeatsPage() {
.sort((a: any, b: any) => a.sequence - b.sequence);
}, [trainCoachesData]);
// Resolve the CoachType UUID for a preview-list coach. The preview API now returns
// coachTypeId directly; fall back to cross-referencing the seatmap data for older
// API versions that may not include it.
const resolveCoachTypeIdFromSeatmap = useCallback((previewCoach: any): string | null => {
if (previewCoach.coachTypeId) return previewCoach.coachTypeId;
// Fallback: match by label against the current seatmap coaches
const previewLabel = previewCoach.label || previewCoach.coachNumber || "";
const allSeatmapCoaches: any[] = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || [];
const match = allSeatmapCoaches.find(
(c: any) => (c.label || c.coachNumber || "") === previewLabel ||
c.id === previewCoach.coachId ||
c.id === previewCoach.id
);
return match?.coachTypeId ?? null;
}, [seatMapData]);
const isDiningCoachType = (type: string) => /dining|dpc/i.test(type);
// Looks up a coach type's lowest per-adult fare from the coach-type/fare data captured
@@ -343,10 +427,10 @@ export default function SeatsPage() {
const getSeatFare = useCallback(
(seat: any): number | null => {
if (!currentCoachTypeClasses.length) return null;
if (seat?.bedPosition) {
if (seat?.bedPosition) {
const match = currentCoachTypeClasses.find((c: any) =>
c.name?.toLowerCase().includes(seat.bedPosition),
);
);
if (match) return match.baseFareMinor;
}
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
@@ -363,17 +447,15 @@ export default function SeatsPage() {
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
const firstClass = matchedType.classes?.[0];
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
const updatedSchedule = {
...(currentSchedule as any),
selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId,
selectedCoachTypeCode: matchedType.coachTypeCode,
selectedCoachTypeName: matchedType.coachTypeName,
selectedSeatClass: firstClass?.name || matchedType.coachTypeName,
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName,
// review/page.tsx's fare-breakdown request reads THIS field (not
// selectedSeatClassName) to resolve the seat class — must stay in sync or the
// review page keeps pricing against the coach type the user switched away from.
seatClassName: firstClass?.name || matchedType.coachTypeName,
selectedCoachTypeId: newCoachTypeId,
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
};
@@ -382,6 +464,11 @@ export default function SeatsPage() {
setInboundSchedule(updatedSchedule);
} else if (isRoundTrip) {
setOutboundSchedule(updatedSchedule);
// For package bookings both legs always use the same coach type — mirror the
// switch to the inbound schedule so the auto-assign fetches the right seatmap.
if (isPackageBooking && inboundSchedule) {
setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id });
}
} else {
setSelectedSchedule(updatedSchedule);
}
@@ -391,6 +478,34 @@ export default function SeatsPage() {
setSelectedCoach(null);
setPendingCoachLabel(coach.label);
setShowCoachPreview(false);
// The user already confirmed this fare change in the coach-switch modal above — reset
// the per-leg baseline to the NEW coach type's fare so the very next seat pick is
// compared against it, not the stale pre-switch fare. Without this, picking any seat
// right after switching would immediately re-trigger the fare-change modal in
// handleSeatClick for a change the user already agreed to.
if (newFare != null) {
if (isRoundTrip && currentJourneyType === "inbound") {
originalFaresRef.current.inbound = newFare;
} else if (isRoundTrip) {
originalFaresRef.current.outbound = newFare;
} else {
originalFaresRef.current.oneWay = newFare;
}
}
// For package bookings, sync the stored tier price with the new coach type's fare
// so the review page totals reflect the switched coach type.
if (isPackageBooking && packageId && newFare != null) {
setPackageContext(
packageId,
priceTierId ?? '',
newFare,
packageName ?? undefined,
packageDepartureStationId ?? undefined,
packageDepartureStationName ?? undefined,
);
}
};
// Same coach type as the one already loaded — no refetch needed, just bring this
@@ -401,12 +516,6 @@ export default function SeatsPage() {
setShowCoachPreview(false);
};
// Coach card click handler for the Train Coach Preview: validates availability, then
// immediately loads that coach's seat map (switching coach type if needed) — no price
// confirmation here. Individual seats within a coach type/bed coach can still be priced
// differently (e.g. Upper/Middle/Lower berths), so the fare confirmation instead happens
// at the point of actually picking a seat (see handleSeatClick), once real seat data is
// in view.
const handlePreviewCoachSelect = (coach: any) => {
if (coach.remainingSeats != null && coach.remainingSeats <= 0) {
setModalState({
@@ -416,31 +525,71 @@ export default function SeatsPage() {
type: "warning",
onConfirm: undefined,
showCancel: false,
confirmText: "OK",
});
return;
}
const types = (currentSchedule as any)?.coachTypes || [];
const matchedType = types.find(
(ct: any) =>
ct.coachTypeId === coach.coachTypeId ||
ct.coachId === coach.coachTypeId ||
ct.coachTypeCode === coach.type ||
ct.coachTypeName === coach.type,
);
const isSameType =
matchedType &&
(matchedType.coachTypeId === coachTypeId || matchedType.coachId === coachTypeId);
// Resolve the real CoachType UUID by cross-referencing the seatmap data,
// since the preview API (/seats/coaches) returns physical coach IDs, not CoachType UUIDs.
const resolvedCoachTypeId = resolveCoachTypeIdFromSeatmap(coach);
if (!matchedType || isSameType) {
// Same coach type — just bring this physical coach's seat map into view.
// Same coach type as currently loaded — just scroll to it.
// Only trust the resolved UUID; name/code comparisons are unreliable across
// different API responses and cause false positives for package bookings.
const isSameType = !!resolvedCoachTypeId && resolvedCoachTypeId === coachTypeId;
if (isSameType) {
focusCoachInPlace(coach);
return;
}
// Different coach type — switch to it and load its seat map; per-seat fare
// confirmation (if any) happens once the user picks an actual seat.
applyCoachTypeSwitch(coach, matchedType);
// Different coach type — build matchedType from coachTypes array or synthesise.
const types = (currentSchedule as any)?.coachTypes || [];
const matchedType = types.find(
(ct: any) =>
(resolvedCoachTypeId && (ct.coachTypeId === resolvedCoachTypeId || ct.coachId === resolvedCoachTypeId)) ||
(coach.coachTypeName && ct.coachTypeName === coach.coachTypeName) ||
(coach.type && (ct.coachTypeCode === coach.type || ct.coachTypeName === coach.type)),
) ?? {
coachTypeId: resolvedCoachTypeId,
coachId: resolvedCoachTypeId,
coachTypeName: coach.coachTypeName || coach.typeName || coach.type || "",
coachTypeCode: coach.type || "",
classes: [],
};
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
const currentFare = originalFareForCurrentLeg ?? (currentSchedule as any)?.baseFareAdult ?? null;
// Always confirm when switching to a different coach type — show fare difference
// if known, or a generic confirmation if fares can't be resolved.
if (newFare != null && currentFare != null && newFare !== currentFare) {
setModalState({
isOpen: true,
title: "Fare Will Change",
message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
type: "warning",
showCancel: true,
confirmText: "Switch Coach",
onConfirm: () => applyCoachTypeSwitch(coach, matchedType),
});
return;
}
// Same fare or fare unknown — still confirm the coach type switch.
const coachTypeName = matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "";
setModalState({
isOpen: true,
title: "Switch Coach Type",
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
}`,
type: "info",
showCancel: true,
confirmText: "Switch Coach",
onConfirm: () => applyCoachTypeSwitch(coach, matchedType),
});
};
const holdMutation = useMutation({
@@ -660,6 +809,8 @@ export default function SeatsPage() {
next[activePassengerIndex] = seatId;
}
setPassengerSeatMap(next);
// Whatever happens now is a deliberate pick — no longer just a restored hold.
restoredIndicesRef.current.delete(activePassengerIndex);
if (!isDeselecting) {
// Move on to the next passenger who still needs a seat — one passenger at a time
@@ -680,26 +831,48 @@ export default function SeatsPage() {
);
if (takenByOther) return;
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
const currentSeatId = passengerSeatMap[activePassengerIndex];
// A seat restored from a still-valid hold (see the restore effect above) isn't a
// choice this passenger has made THIS visit — their next click is their first real
// pick, not a "change", even though the map already has an entry for them.
const isRestoredNotYetChosen = restoredIndicesRef.current.has(activePassengerIndex);
const isDeselecting = currentSeatId === seatId;
if (isDeselecting) {
restoredIndicesRef.current.delete(activePassengerIndex);
commitSeatAssignment(seatId);
return;
}
// Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare
// differs from what the user originally selected (e.g. after switching coach type
// via the preview, or just picking a pricier berth) — or from another passenger's
// already-selected seat — needs a heads-up before it's applied.
// A fare-change warning only makes sense when there's an actual prior selection to
// change FROM — either this same passenger swapping their own pick for a
// differently-priced one, or picking a seat priced differently from another
// passenger's already-selected seat this session. A passenger's very first pick has
// neither, so it must never trigger this modal, regardless of the coach type's
// "starting from" fare.
const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null;
if (newFare != null) {
let referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected";
let referenceLabel = "your previously selected seat";
if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
referenceFare = originalFareForCurrentLeg;
if (isPackageBooking) {
// For package bookings, compare against the stored tier price (per leg).
const pkgLegFare = packageTierPriceMinor;
if (pkgLegFare != null && newFare !== pkgLegFare) {
referenceFare = pkgLegFare;
}
} else if (currentSeatId && !isRestoredNotYetChosen) {
// This passenger already has a different seat picked — compare against that
// actual, concrete selection.
const currentSeat = validSeats?.find((s: any) => s.id === currentSeatId);
const currentFare = currentSeat ? getSeatFare(currentSeat) : null;
if (currentFare != null && currentFare !== newFare) {
referenceFare = currentFare;
}
} else {
// First pick for this passenger — only compare against another passenger's
// already-selected seat in this session.
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
if (Number(idx) === activePassengerIndex) return false;
const otherSeat = validSeats?.find((s: any) => s.id === sid);
@@ -719,28 +892,91 @@ export default function SeatsPage() {
const positionLabel = newSeat?.bedPosition
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
: "This seat";
const legMultiplier = isRoundTrip ? 2 : 1;
setModalState({
isOpen: true,
title: "Fare Will Change",
message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`,
message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
type: "warning",
showCancel: true,
onConfirm: () => commitSeatAssignment(seatId),
confirmText: "Continue",
onConfirm: () => {
commitSeatAssignment(seatId);
// For package bookings, sync the stored tier price to the selected berth fare
// so review/payment/confirmation pages use the correct amount.
if (isPackageBooking && packageId) {
setPackageContext(
packageId,
priceTierId ?? '',
newFare,
packageName ?? undefined,
packageDepartureStationId ?? undefined,
packageDepartureStationName ?? undefined,
);
}
},
});
return;
}
// No fare change — but for package bookings still sync the tier price to the
// actual berth fare (handles the case where the first seat picked matches the
// stored price but we still want it explicitly confirmed).
if (isPackageBooking && packageId && newFare !== packageTierPriceMinor) {
setPackageContext(
packageId,
priceTierId ?? '',
newFare,
packageName ?? undefined,
packageDepartureStationId ?? undefined,
packageDepartureStationName ?? undefined,
);
}
}
commitSeatAssignment(seatId);
},
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment],
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip],
);
const allSeatsAssigned =
seatEligibleIndices.length > 0 &&
seatEligibleIndices.every((i) => !!passengerSeatMap[i]);
// Only ever hold seats once per leg. If the current on-screen picks are exactly what's
// already held (valid, unexpired), skip the API call entirely and reuse that hold — this
// is what stops "back, then Continue again" from stacking up a second hold on the same
// seats. If the user genuinely picked different seats than what was previously held,
// release the stale hold first (best-effort — an already-expired/released hold shouldn't
// block picking the new seat(s)) before holding the new selection, so at most one hold for
// this leg is ever active at a time — for both guest and authenticated sessions.
const ensureLegHold = async (seatIdsForHold: string[]) => {
const selectionMatchesExistingHold =
isCurrentLegHoldValid &&
seatEligibleIndices.every((i) => passengerSeatMap[i] === previouslyHeldSeatIds[i]);
if (selectionMatchesExistingHold) {
return;
}
if (isCurrentLegHoldValid && currentLegHoldId) {
try {
// Routed through the portal's own server-side proxy (not called on the backend
// directly) so the release capability never appears in client-side network calls.
await fetch("/api/seats/release-hold", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ holdId: currentLegHoldId }),
});
} catch {
// Best-effort — an expired/already-released hold shouldn't block picking the new seat(s).
}
}
await holdMutation.mutateAsync(seatIdsForHold);
};
const handleContinue = async () => {
if (!allSeatsAssigned) return;
// Indexed by original passenger position — holes for passengers who share a seat
@@ -751,7 +987,7 @@ export default function SeatsPage() {
if (isRoundTrip && currentJourneyType === "outbound") {
try {
await holdMutation.mutateAsync(seatIdsForHold);
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
return {
@@ -764,6 +1000,152 @@ export default function SeatsPage() {
};
});
setPassengers(updatedPassengers);
if (isPackageBooking && inboundSchedule) {
setAutoAssigningReturn(true);
try {
// Package bookings always use the same coach type for both legs — use the
// outbound's (just-confirmed) coachTypeId so a prior coach-type switch is
// reflected in the inbound seatmap fetch even if inboundSchedule wasn't updated.
const inboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId || (inboundSchedule as any).selectedCoachTypeId;
const inboundOriginId = (inboundSchedule as any).originStationId || searchCriteria?.destinationStationId;
const inboundDestId = (inboundSchedule as any).destinationStationId || searchCriteria?.originStationId;
// Fetch the full outbound seatmap to resolve seat number/bedPosition by ID —
// validSeats only holds the currently-expanded coach and may be empty.
const outboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId;
const outboundOriginId = (currentSchedule as any)?.originStationId || searchCriteria?.originStationId;
const outboundDestId = (currentSchedule as any)?.destinationStationId || searchCriteria?.destinationStationId;
const outboundMapData: any = await apiClient.get(
`/seats/seatmap/${currentSchedule?.id}?coachTypeId=${outboundCoachTypeId}&journeyDirection=OUTBOUND${outboundOriginId ? `&originStationId=${outboundOriginId}` : ''}${outboundDestId ? `&destinationStationId=${outboundDestId}` : ''}`
);
const outboundCoaches: any[] = (outboundMapData as any)?.coaches || (outboundMapData as any)?.data?.coaches || [];
const allOutboundSeats: any[] = [];
outboundCoaches.forEach((c: any) => {
if (c.rooms?.length > 0) {
c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allOutboundSeats.push(b)));
} else {
(c.seats || []).forEach((s: any) => allOutboundSeats.push(s));
}
});
const inboundMapData: any = await apiClient.get(
`/seats/seatmap/${inboundSchedule.id}?coachTypeId=${inboundCoachTypeId}&journeyDirection=RETURN${inboundOriginId ? `&originStationId=${inboundOriginId}` : ''}${inboundDestId ? `&destinationStationId=${inboundDestId}` : ''}`
);
const inboundCoaches: any[] = (inboundMapData as any)?.coaches || (inboundMapData as any)?.data?.coaches || [];
const allInboundSeats: any[] = [];
inboundCoaches.forEach((c: any) => {
const coachLabel = c.label || c.name || c.coachNumber || '';
if (c.rooms?.length > 0) {
c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allInboundSeats.push({ ...b, _coachLabel: coachLabel })));
} else {
(c.seats || []).forEach((s: any) => allInboundSeats.push({ ...s, _coachLabel: coachLabel }));
}
});
const claimedIds = new Set<string>();
const inboundSeatMap: Record<number, string> = {};
for (const i of seatEligibleIndices) {
// Resolve outbound seat from the full seatmap fetch (not validSeats which
// only holds the currently-expanded coach and is often empty).
const outboundSeat = allOutboundSeats.find((s: any) => s.id === seatIds[i]);
const outboundBase = outboundSeat ? (outboundSeat.number || outboundSeat.label || outboundSeat.seatNumber || '') : '';
const outboundBedPos: string | null = outboundSeat?.bedPosition || null;
// Priority 1: exact same seat number + same bed position
// Priority 2: same bed position, any available seat
// Priority 3: any available seat (fallback)
const match =
allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && (s.number || s.label || s.seatNumber || '') === outboundBase && (!outboundBedPos || s.bedPosition === outboundBedPos)) ||
allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && outboundBedPos && s.bedPosition === outboundBedPos) ||
allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id));
if (match) {
inboundSeatMap[i] = match.id;
claimedIds.add(match.id);
}
}
if (claimedIds.size < seatEligibleIndices.length) {
// Not enough inbound seats found — fall through to manual inbound selection
setAutoAssigningReturn(false);
setCurrentJourneyType("inbound");
setPassengerSeatMap({});
setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
setSelectedCoach(null);
return;
}
// Hold inbound seats directly — holdMutation reads currentJourneyType
// which is still "outbound" at this point, so we call the API directly.
const inboundHoldData: any = await apiClient.post('/seats/hold', {
scheduleId: inboundSchedule.id,
originStationId: inboundOriginId,
destinationStationId: inboundDestId,
journeyDirection: 'RETURN',
passengers: seatEligibleIndices.map((i, offset) => ({
passengerId: `temp-${Date.now()}-${offset}`,
seatId: inboundSeatMap[i],
})),
});
const currentHold = useBookingStore.getState().seatHold;
setSeatHold({
holdId: currentHold?.holdId || '',
expiresAt: currentHold?.expiresAt || '',
returnHoldId: inboundHoldData.holdId || inboundHoldData.id,
returnExpiresAt: inboundHoldData.expiresAt,
});
const withInbound = updatedPassengers.map((p, i) => {
const inboundSeatId = inboundSeatMap[i];
const inboundSeatData = inboundSeatId ? allInboundSeats.find((s: any) => s.id === inboundSeatId) : undefined;
return {
...p,
inboundSeatId,
inboundSeatNumber: inboundSeatData ? buildSeatLabel(inboundSeatData) : '',
inboundCoachNumber: inboundSeatData?._coachLabel || '',
inboundSeatFareMinor: undefined,
inboundBedPosition: inboundSeatData?.bedPosition || undefined,
};
});
setPassengers(withInbound);
// Update the stored tier price with the actual berth fare so the review page
// reflects the correct price when the user picks Upper/Middle/Lower berths
// (which are priced differently within the same coach type).
if (packageId) {
const firstEligibleIdx = seatEligibleIndices[0];
const outboundSeat = firstEligibleIdx != null
? allOutboundSeats.find((s: any) => s.id === seatIds[firstEligibleIdx])
: null;
const berthFare = outboundSeat ? getSeatFare(outboundSeat) : null;
if (berthFare != null) {
setPackageContext(
packageId,
priceTierId ?? '',
berthFare,
packageName ?? undefined,
packageDepartureStationId ?? undefined,
packageDepartureStationName ?? undefined,
);
}
}
router.push('/booking/review');
return;
} catch {
// Auto-assign failed — fall through to manual inbound selection
setAutoAssigningReturn(false);
setCurrentJourneyType("inbound");
setPassengerSeatMap({});
setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
setSelectedCoach(null);
return;
}
}
} catch (error: any) {
setModalState({
isOpen: true,
@@ -774,6 +1156,7 @@ export default function SeatsPage() {
type: "error",
onConfirm: undefined,
showCancel: false,
confirmText: "OK",
});
return;
}
@@ -785,7 +1168,7 @@ export default function SeatsPage() {
}
try {
await holdMutation.mutateAsync(seatIdsForHold);
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
if (isRoundTrip && currentJourneyType === "inbound") {
@@ -808,6 +1191,26 @@ export default function SeatsPage() {
};
});
setPassengers(updatedPassengers);
// For one-way package bookings, sync the stored tier price with the actual berth
// fare so review/payment/confirmation pages reflect the correct amount.
if (!isRoundTrip && isPackageBooking && packageId) {
const firstEligibleIdx = seatEligibleIndices[0];
const firstSeatData = firstEligibleIdx != null
? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
: null;
const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null;
if (berthFare != null) {
setPackageContext(
packageId,
priceTierId ?? '',
berthFare,
packageName ?? undefined,
packageDepartureStationId ?? undefined,
packageDepartureStationName ?? undefined,
);
}
}
} catch (error: any) {
setModalState({
isOpen: true,
@@ -818,6 +1221,7 @@ export default function SeatsPage() {
type: "error",
onConfirm: undefined,
showCancel: false,
confirmText: "OK",
});
return;
}
@@ -841,6 +1245,7 @@ export default function SeatsPage() {
type: "warning",
onConfirm: undefined,
showCancel: false,
confirmText: "OK",
});
return;
}
@@ -1323,7 +1728,9 @@ export default function SeatsPage() {
const isCurrentType =
!!coachTypeId &&
(coach.coachTypeId === coachTypeId ||
coach.coachId === coachTypeId ||
coach.type === (currentSchedule as any)?.selectedCoachTypeCode ||
coach.coachTypeName === (currentSchedule as any)?.selectedCoachTypeName ||
coach.type === (currentSchedule as any)?.selectedCoachTypeName);
return (
<div key={coach.id}>
@@ -1386,23 +1793,29 @@ export default function SeatsPage() {
</>
);
// Summary card content — shared between sidebar and mobile modal
const SummaryContent = () => (
// Summary content, split into pieces so the mobile bottom-sheet can show a compact
// header + always-reachable Continue button by default, and only reveal the full
// passenger list (SummaryDetails) when the user explicitly expands it — otherwise it
// permanently covers most of the seat map on small screens.
const SummaryHeader = () => (
<div className="flex items-center justify-between mb-1">
<h3 className="font-bold text-gray-900 dark:text-white">
Selection Summary
</h3>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
allSeatsAssigned
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
}`}
>
{assignedCount}/{seatEligibleIndices.length} selected
</span>
</div>
);
const SummaryDetails = () => (
<>
<div className="flex items-center justify-between mb-1">
<h3 className="font-bold text-gray-900 dark:text-white">
Selection Summary
</h3>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
allSeatsAssigned
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
}`}
>
{assignedCount}/{seatEligibleIndices.length} selected
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{allSeatsAssigned
? "All seats selected — ready to continue"
@@ -1448,6 +1861,9 @@ export default function SeatsPage() {
? validSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
const seatFare = assignedSeat
? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
: isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
@@ -1489,36 +1905,67 @@ export default function SeatsPage() {
)}
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
</span>
<div className="flex flex-col items-end flex-shrink-0">
<span
className={`text-sm font-semibold ${
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
</span>
{assignedSeat && seatFare != null && (
<span className="text-[11px] text-gray-500 dark:text-gray-400">
ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
</span>
)}
</div>
</button>
);
})}
</div>
</>
);
<button
onClick={handleContinue}
disabled={!allSeatsAssigned || 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
const SummaryContinueButton = () => (
<button
onClick={handleContinue}
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
className="w-full py-2.5 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"
>
{autoAssigningReturn
? "Assigning return seats..."
: holdMutation.isPending
? "Holding seats..."
: isRoundTrip && currentJourneyType === "outbound"
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
? "Continue to Return Seats"
: "Continue"}
</button>
<button
onClick={handleAutoAssign}
disabled={holdMutation.isPending || allSeatsAssigned}
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
>
Auto Assign Seats
</button>
</button>
);
const SummaryAutoAssignButton = () => (
<button
onClick={handleAutoAssign}
disabled={holdMutation.isPending || allSeatsAssigned}
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
>
Auto Assign Seats
</button>
);
const SummaryActions = () => (
<>
<SummaryContinueButton />
<SummaryAutoAssignButton />
</>
);
// Full summary card — used as-is in the desktop sidebar, which has room to show
// everything at once.
const SummaryContent = () => (
<>
<SummaryHeader />
<SummaryDetails />
<SummaryActions />
</>
);
@@ -1526,13 +1973,13 @@ export default function SeatsPage() {
<>
<CustomModal
isOpen={modalState.isOpen}
onClose={() => setModalState({ ...modalState, isOpen: false })}
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title}
message={modalState.message}
type={modalState.type}
onConfirm={modalState.onConfirm}
showCancel={modalState.showCancel}
confirmText={modalState.showCancel ? "Switch Coach" : "OK"}
confirmText={modalState.confirmText}
/>
{/* Train Coach Preview */}
@@ -1561,7 +2008,7 @@ export default function SeatsPage() {
{/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */}
<div
className="hidden lg:flex fixed inset-y-0 right-0 z-[120] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col"
className="hidden lg:flex fixed inset-y-0 right-0 z-[90] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col"
style={{ animation: "drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)" }}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
@@ -1586,15 +2033,41 @@ export default function SeatsPage() {
</>
)}
{/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
{/* Mobile summary bottom-sheet — collapsed to a single slim row by default so it
barely dents the seat map; tap it to expand the full passenger list + auto-assign.
The Continue button always stays visible either way. */}
<div
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5 max-h-[70vh] overflow-y-auto"
className={`fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl overflow-hidden flex flex-col ${
mobileSummaryExpanded ? "max-h-[70vh]" : ""
}`}
style={{
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
}}
>
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
<SummaryContent />
<button
type="button"
onClick={() => setMobileSummaryExpanded((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 flex-shrink-0"
aria-label={mobileSummaryExpanded ? "Collapse seat selection summary" : "Expand seat selection summary"}
>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{assignedCount}/{seatEligibleIndices.length} seats selected
</span>
<ChevronDown
className={`w-4 h-4 text-gray-400 transition-transform ${mobileSummaryExpanded ? "rotate-180" : ""}`}
/>
</button>
{mobileSummaryExpanded && (
<div className="px-5 overflow-y-auto flex-1 min-h-0">
<SummaryDetails />
<SummaryAutoAssignButton />
</div>
)}
<div className="px-5 pb-4 pt-2 flex-shrink-0 border-t border-gray-100 dark:border-gray-800">
<SummaryContinueButton />
</div>
</div>
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
@@ -1612,11 +2085,13 @@ export default function SeatsPage() {
</button>
<div className="text-center">
<h1 className="text-base font-bold text-gray-900 dark:text-white">
{isRoundTrip
? currentJourneyType === "outbound"
? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats"
: packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats"
: "Select Seats"}
{isPackageBooking
? `Select Seats for ${packageName}`
: isRoundTrip
? currentJourneyType === "outbound"
? "Select Outbound Seats"
: "Select Return Seats"
: "Select Seats"}
</h1>
{!allSeatsAssigned && (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -1912,8 +2387,9 @@ export default function SeatsPage() {
</div>
</div>
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
<div className="h-48 lg:hidden" />
{/* Mobile spacer so the bottom-sheet doesn't cover the last seat — sized to
match the sheet's current (collapsed/expanded) height. */}
<div className={`lg:hidden ${mobileSummaryExpanded ? "h-[70vh]" : "h-24"}`} />
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import SupportWidget from '@/features/support/SupportWidget';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
@@ -51,6 +52,7 @@ export default function RootLayout({
{children}
</main>
<Footer />
<SupportWidget />
</Providers>
</body>
</html>

View File

@@ -365,6 +365,7 @@ const PKG_CHILDREN_PER_ADULT = 5;
function PassengerCountModal({
tier,
minPriceMinor,
onClose,
onConfirm,
loading,
@@ -373,6 +374,7 @@ function PassengerCountModal({
stations,
}: {
tier: PriceTier;
minPriceMinor: number;
onClose: () => void;
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
loading: boolean;
@@ -389,7 +391,7 @@ function PassengerCountModal({
const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - adultCount);
// Only paid children need seats; free children share with an adult
const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier;
const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier;
return (
<>
@@ -406,7 +408,7 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
</div>
<div className="px-6 py-5 space-y-4">
@@ -442,7 +444,7 @@ function PassengerCountModal({
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
</div>
@@ -557,6 +559,15 @@ export default function PackageDetailPage() {
selectedSeatClassName: ctx.seatClassName ?? "",
seatClassName: ctx.seatClassName ?? "",
selectedCoachTypeId: ctx.coachTypeId ?? "",
selectedCoachTypeCode: ctx.coachTypeCode ?? "",
selectedCoachTypeName: ctx.coachTypeName ?? "",
coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({
coachId: g.coachTypeId,
coachTypeId: g.coachTypeId,
coachTypeName: g.coachTypeName,
coachTypeCode: g.coachTypeCode,
classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })),
})),
});
const outboundSched = toSchedule(ctx.outboundSchedule);
@@ -648,6 +659,7 @@ export default function PackageDetailPage() {
{passengerModalOpen && representativeTier && (
<PassengerCountModal
tier={representativeTier}
minPriceMinor={selectedGroup?.minPrice ?? representativeTier.priceMinor}
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
onConfirm={handleBookNow}
loading={bookingContextLoading}

View File

@@ -0,0 +1,445 @@
'use client';
import { Passenger } from '@edr/types';
import { ArrowLeft, Headset, Plus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useCreateConversation,
useMarkRead,
useMessages,
useSendMessage,
} from './useSupport';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
type View = { kind: 'list' } | { kind: 'new' } | { kind: 'thread'; id: string };
export function SupportPanel({
onClose,
isGuest = false,
}: {
onClose: () => void;
isGuest?: boolean;
}) {
const [view, setView] = useState<View>({ kind: 'list' });
return (
<div className="flex h-[560px] max-h-[calc(100vh-120px)] w-[min(384px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
{view.kind === 'list' && (
<ConversationList
onClose={onClose}
onNew={() => setView({ kind: 'new' })}
onOpen={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'new' && (
<NewConversation
isGuest={isGuest}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
onCreated={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'thread' && (
<Thread
conversationId={view.id}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
/>
)}
</div>
);
}
function Header({
title,
subtitle,
onClose,
onBack,
}: {
title: string;
subtitle?: string;
onClose: () => void;
onBack?: () => void;
}) {
return (
<div
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
style={{
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
}}
>
<div className="flex min-w-0 items-center gap-2">
{onBack ? (
<button
onClick={onBack}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Back"
>
<ArrowLeft size={20} />
</button>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<Headset size={18} />
</span>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{title}</p>
{subtitle && (
<p className="truncate text-xs text-white/80">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Close"
>
<X size={20} />
</button>
</div>
);
}
function ConversationList({
onClose,
onNew,
onOpen,
}: {
onClose: () => void;
onNew: () => void;
onOpen: (id: string) => void;
}) {
const { data, isLoading } = useConversations();
const items = data?.items ?? [];
return (
<>
<Header
title="Support"
subtitle="We usually reply within a few minutes"
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center text-sm text-gray-500 dark:text-slate-400">
<span
className="flex h-12 w-12 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={24} />
</span>
No conversations yet. Start one and our team will help you out.
</div>
) : (
items.map((c) => (
<ConversationRow key={c.id} c={c} onClick={() => onOpen(c.id)} />
))
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<button
onClick={onNew}
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
style={{ background: GREEN }}
>
<Plus size={16} /> New request
</button>
</div>
</>
);
}
function ConversationRow({
c,
onClick,
}: {
c: ConversationDto;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-gray-100 px-4 py-3 text-left transition hover:bg-gray-50 dark:border-slate-800 dark:hover:bg-slate-800/60 ${
unread ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread
? 'font-bold text-gray-900 dark:text-white'
: 'font-semibold text-gray-800 dark:text-slate-200'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-gray-400">
{formatTime(c.lastMessageAt)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-500 dark:text-slate-400">
{c.lastMessageSender === 'AGENT' ? 'Support: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function NewConversation({
isGuest,
onClose,
onBack,
onCreated,
}: {
isGuest: boolean;
onClose: () => void;
onBack: () => void;
onCreated: (id: string) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const create = useCreateConversation();
const guestValid =
!isGuest || (name.trim().length > 0 && /.+@.+\..+/.test(email.trim()));
const valid =
subject.trim().length >= 3 && message.trim().length > 0 && guestValid;
const submit = async () => {
if (!valid) return;
const conv = await create.mutateAsync({
subject: subject.trim(),
initialMessage: message.trim(),
...(isGuest ? { name: name.trim(), email: email.trim() } : {}),
});
onCreated(conv.id);
};
const inputClass =
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white';
return (
<>
<Header title="New request" onClose={onClose} onBack={onBack} />
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{isGuest && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Your name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={inputClass}
/>
</div>
</>
)}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Subject
</label>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Refund for booking EDR-1234"
className={inputClass}
/>
</div>
<div className="flex flex-1 flex-col">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
How can we help?
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your issue…"
className="min-h-[120px] flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
</div>
<button
onClick={submit}
disabled={!valid || create.isPending}
className="flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
>
<Send size={16} /> {create.isPending ? 'Sending…' : 'Send request'}
</button>
</div>
</>
);
}
function Thread({
conversationId,
onClose,
onBack,
}: {
conversationId: string;
onClose: () => void;
onBack: () => void;
}) {
const { data: conversations } = useConversations();
const conversation = useMemo(
() => conversations?.items.find((c) => c.id === conversationId),
[conversations, conversationId],
);
const { data: messages, isLoading } = useMessages(conversationId);
const send = useSendMessage(conversationId);
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
if (conversationId) markRead.mutate(conversationId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
return (
<>
<Header
title={conversation?.subject || 'Conversation'}
subtitle={
conversation ? `Status: ${conversation.status.toLowerCase()}` : undefined
}
onClose={onClose}
onBack={onBack}
/>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : (
(messages ?? []).map((m) => <MessageBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type a message…"
className="max-h-24 flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</>
);
}
function MessageBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'USER';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className="max-w-[78%]">
{!mine && (
<p className="mb-0.5 ml-1 text-xs text-gray-400">
{m.authorName || 'Support agent'}
</p>
)}
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-100'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-gray-400 ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}
export default SupportPanel;

View File

@@ -0,0 +1,51 @@
'use client';
import { Headset, MessageCircle } from 'lucide-react';
import { useState } from 'react';
import { useAuthStore } from '@/lib/auth-store';
import { SupportPanel } from './SupportPanel';
import { useUnreadCount } from './useSupport';
import { useSupportSocket } from './useSupportSocket';
const GREEN = 'rgb(20 113 76)';
/**
* Floating passenger-support launcher, mounted in the app shell for everyone —
* authenticated passengers and guests (guests are scoped by a localStorage
* guestId). Live pushes keep the unread badge fresh.
*/
export function SupportWidget() {
const { isAuthenticated } = useAuthStore();
const [open, setOpen] = useState(false);
const { data } = useUnreadCount(true);
const unread = data?.unreadCount ?? 0;
// Authenticated users keep a live socket for background pushes; guests connect
// once they open the panel (avoids idle sockets for visitors who never chat).
useSupportSocket(isAuthenticated || open);
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
{open && <SupportPanel onClose={() => setOpen(false)} isGuest={!isAuthenticated} />}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open support chat"
className="relative flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg transition hover:scale-105"
style={{ background: GREEN, boxShadow: '0 8px 24px rgba(20,113,76,0.4)' }}
>
{unread > 0 ? <Headset size={26} /> : <MessageCircle size={26} />}
{unread > 0 && (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full border-2 border-white bg-red-500 px-1 text-xs font-bold text-white">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
)}
</div>
);
}
export default SupportWidget;

View File

@@ -0,0 +1,24 @@
'use client';
const GUEST_KEY = 'support_guest_id';
/** True when a real passenger auth token is present. */
export function isAuthed(): boolean {
if (typeof window === 'undefined') return false;
const t = localStorage.getItem('auth_token');
return !!t && t !== 'null' && t !== 'undefined';
}
/** Stable anonymous id for guest conversations (persisted in localStorage). */
export function getGuestId(): string {
if (typeof window === 'undefined') return '';
let id = localStorage.getItem(GUEST_KEY);
if (!id) {
id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `guest-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
localStorage.setItem(GUEST_KEY, id);
}
return id;
}

View File

@@ -0,0 +1,75 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
import { getGuestId, isAuthed } from './guestIdentity';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface CreateInput {
subject: string;
initialMessage: string;
// Required only for guests:
name?: string;
email?: string;
phone?: string;
}
/**
* Passenger portal support-chat calls. Transparently uses the authenticated
* (`/support/...`) or guest (`/support/guest/...`) endpoints depending on whether
* a passenger auth token is present. Guests are scoped by a localStorage guestId.
*/
export const supportApi = {
listConversations: (): Promise<ListResult> =>
isAuthed()
? apiClient.get('/support/conversations')
: apiClient.get('/support/guest/conversations', {
params: { guestId: getGuestId() },
}),
createConversation: (input: CreateInput): Promise<ConversationDto> =>
isAuthed()
? apiClient.post('/support/conversations', {
subject: input.subject,
initialMessage: input.initialMessage,
})
: apiClient.post('/support/guest/conversations', {
guestId: getGuestId(),
name: input.name,
email: input.email,
phone: input.phone,
subject: input.subject,
initialMessage: input.initialMessage,
}),
listMessages: (id: string): Promise<MessageDto[]> =>
isAuthed()
? apiClient.get(`/support/conversations/${id}/messages`)
: apiClient.get(`/support/guest/conversations/${id}/messages`, {
params: { guestId: getGuestId() },
}),
sendMessage: (id: string, text: string): Promise<MessageDto> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/messages`, { text })
: apiClient.post(`/support/guest/conversations/${id}/messages`, {
guestId: getGuestId(),
text,
}),
markRead: (id: string): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/read`)
: apiClient.post(`/support/guest/conversations/${id}/read`, {
guestId: getGuestId(),
}),
unreadCount: (): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.get('/support/unread-count')
: apiClient.get('/support/guest/unread-count', {
params: { guestId: getGuestId() },
}),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type CreateInput } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(enabled = true) {
return useQuery({
queryKey: SUPPORT_CONVERSATIONS_KEY,
queryFn: () => supportApi.listConversations(),
enabled,
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateInput) => supportApi.createConversation(input),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import { getGuestId, isAuthed } from './guestIdentity';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in passenger to live support pushes. New messages
* refresh the affected thread + list + unread badge, and fire `onMessage`
* (the widget toasts when closed).
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
// Authenticated passengers connect with their token; guests connect with
// their anonymous guestId so the gateway can join their `guest:<id>` room.
const auth = isAuthed()
? { token: localStorage.getItem('auth_token') }
: { guestId: getGuestId() };
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth,
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -1,6 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
class ApiClient {
private client: AxiosInstance;
@@ -9,13 +9,16 @@ class ApiClient {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
this.client.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && token !== 'null' && token !== 'undefined') {
const token =
typeof window !== "undefined"
? localStorage.getItem("auth_token")
: null;
if (token && token !== "null" && token !== "undefined") {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
@@ -25,22 +28,28 @@ class ApiClient {
// there just means the user canceled/closed the Fayda popup without completing it (no
// valid verification session) — that should surface as an inline error on the page,
// not force-clear the session and redirect to /login out from under them.
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification'];
const PUBLIC_PREFIXES = [
"/config/",
"/auth/login",
"/auth/register",
"/passengers/me",
"/fayda/verification",
];
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const url: string = error.config?.url || '';
const url: string = error.config?.url || "";
const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
if (!isPublic && typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
window.location.href = '/login';
if (!isPublic && typeof window !== "undefined") {
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user");
// window.location.href = '/login';
}
}
return Promise.reject(error);
}
},
);
}
@@ -50,17 +59,29 @@ class ApiClient {
return response.data?.data || response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async post<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.post<any>(url, data, config);
return response.data?.data || response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async put<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async patch<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}

View File

@@ -109,6 +109,11 @@ interface BookingState {
seatHold: SeatHold | null;
bookingId: string | null;
pnr: string | null;
// The hold id(s) the current bookingId was actually created with — lets the review
// page detect "user came back from the payment gateway with the same booking still
// valid" and reuse it instead of creating a duplicate booking.
bookingHoldId: string | null;
bookingReturnHoldId: string | null;
selectedPaymentMethod: string | null;
createAccount: boolean;
passengerId: string | null;
@@ -133,6 +138,7 @@ interface BookingState {
setSeatHold: (hold: SeatHold | null) => void;
setBookingId: (id: string) => void;
setPNR: (pnr: string) => void;
setBookingHoldReference: (holdId: string | null, returnHoldId?: string | null) => void;
setPaymentMethod: (method: string) => void;
setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
@@ -151,6 +157,8 @@ export const useBookingStore = create<BookingState>()(persist(
seatHold: null,
bookingId: null,
pnr: null,
bookingHoldId: null,
bookingReturnHoldId: null,
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,
@@ -172,6 +180,7 @@ export const useBookingStore = create<BookingState>()(persist(
setSeatHold: (hold) => set({ seatHold: hold }),
setBookingId: (id) => set({ bookingId: id }),
setPNR: (pnr) => set({ pnr }),
setBookingHoldReference: (holdId, returnHoldId) => set({ bookingHoldId: holdId, bookingReturnHoldId: returnHoldId ?? null }),
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }),
@@ -185,6 +194,8 @@ export const useBookingStore = create<BookingState>()(persist(
seatHold: null,
bookingId: null,
pnr: null,
bookingHoldId: null,
bookingReturnHoldId: null,
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,

View File

@@ -1,5 +1,4 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import QRCode from 'qrcode';
interface ScheduleInfo {
@@ -30,12 +29,24 @@ interface PassengerVoucherData {
createdAt: string;
}
// ─── shared drawing helpers ───────────────────────────────────────────────────
// ─── palette ───────────────────────────────────────────────────────────────
// A restrained, mostly-neutral palette (ink / slate / hairline / surface) with the
// brand green reserved for the few elements that should draw the eye — the PNR,
// times, and the fare — rather than tinting large areas of the page.
const PRIMARY = [20, 113, 76] as const;
const DARK = [51, 51, 51] as const;
const MED = [102, 102, 102] as const;
const LIGHT = [200, 200, 200] as const;
const BRAND = [20, 113, 76] as const; // brand green — accents only
const BRAND_SOFT = [235, 245, 240] as const; // pale green tint for subtle fills
const INK = [24, 28, 33] as const; // headings, high-emphasis text
const BODY = [71, 85, 105] as const; // slate-600 — body text
const MUTED = [148, 163, 184] as const; // slate-400 — labels/captions
const HAIRLINE = [226, 232, 240] as const; // slate-200 — borders/dividers
const SURFACE = [250, 250, 251] as const; // near-white card fill
const SUCCESS = [21, 128, 61] as const; // green-700
const AMBER_TEXT = [146, 64, 14] as const; // amber-800
const AMBER_FILL = [255, 251, 235] as const; // amber-50
const AMBER_BORDER = [251, 191, 36] as const; // amber-400
const PAGE_MARGIN = 18;
// ─── QR code ───────────────────────────────────────────────────────────────
// Encodes everything a gate scanner needs to verify this specific ticket without
@@ -63,7 +74,7 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
width: 240,
margin: 0,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' },
color: { dark: '#181c21', light: '#ffffff' },
});
} catch (error) {
console.error('Failed to generate ticket QR code:', error);
@@ -71,11 +82,30 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
}
}
// ─── shared drawing helpers ───────────────────────────────────────────────────
function label(doc: jsPDF, text: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center'; color?: readonly [number, number, number] }): void {
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.5);
const c = opts?.color ?? MUTED;
doc.setTextColor(c[0], c[1], c[2]);
doc.text(text.toUpperCase(), x, y, { align: opts?.align ?? 'left', charSpace: 0.3 });
}
function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.25);
doc.line(x1, y, x2, y);
}
// ─── header ────────────────────────────────────────────────────────────────
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
const pageWidth = doc.internal.pageSize.getWidth();
const bandHeight = 24;
doc.setFillColor(...PRIMARY);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setFillColor(...BRAND);
doc.rect(0, 0, pageWidth, bandHeight, 'F');
try {
const logoImg = await fetch('/edr-logo.png');
@@ -87,206 +117,243 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
});
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
const logoH = 18;
const logoH = 13;
const logoW = (img.width / img.height) * logoH;
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
const textX = margin + logoW + 5;
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
doc.setTextColor(255, 255, 255);
doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
doc.setFontSize(13); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', textX, bandHeight / 2 - 1);
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
doc.setTextColor(230, 240, 236);
doc.text('E-TICKET · BOARDING VOUCHER', textX, bandHeight / 2 + 5, { charSpace: 0.4 });
} catch {
doc.setTextColor(255, 255, 255);
doc.setFontSize(22); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
doc.setFontSize(15); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, bandHeight / 2 - 1, { align: 'center' });
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
doc.setTextColor(230, 240, 236);
doc.text('E-TICKET · BOARDING VOUCHER', pageWidth / 2, bandHeight / 2 + 5, { align: 'center', charSpace: 0.4 });
}
return 40;
return bandHeight + 16;
}
function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
// ─── status pill ───────────────────────────────────────────────────────────
function drawStatusPill(doc: jsPDF, status: string, x: number, y: number, align: 'left' | 'right' = 'right'): void {
const isConfirmed = status === 'TICKETED' || status === 'CONFIRMED';
const text = isConfirmed ? 'CONFIRMED' : status;
const color = isConfirmed ? SUCCESS : [180, 83, 9] as const;
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
const textWidth = doc.getTextWidth(text.toUpperCase());
const padX = 3.5;
const pillH = 5.5;
const pillW = textWidth + padX * 2;
const pillX = align === 'right' ? x - pillW : x;
doc.setFillColor(color[0], color[1], color[2]);
doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
doc.roundedRect(pillX, y, pillW, pillH, pillH / 2, pillH / 2, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
return y + 12;
doc.text(text.toUpperCase(), pillX + pillW / 2, y + pillH / 2 + 1.4, { align: 'center', charSpace: 0.3 });
}
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
const boxHeight = 32;
const qrSize = 24;
const qrPad = 3;
const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0;
// ─── hero card: PNR + ticket number + QR ──────────────────────────────────
doc.setFillColor(245, 245, 245);
doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F');
function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, status: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
const cardH = 32;
const qrSize = 22;
const qrPad = 2.5;
const cardSize = qrSize + qrPad * 2;
// Booking reference (top-left)
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('BOOKING REFERENCE', margin + 5, y + 8);
doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text(bookingRef, margin + 5, y + 18);
doc.setFillColor(...SURFACE);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
// Ticket number, stacked below — leaves room on the right for the QR block
const textRightBound = pageWidth - margin - qrBlockWidth - 5;
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' });
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' });
const padX = 7;
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
label(doc, 'Booking reference', margin + padX, y + 12);
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
doc.text(bookingRef, margin + padX, y + 23, { charSpace: 0.6 });
label(doc, 'Ticket no.', margin + padX, y + 28.5);
doc.setTextColor(...BODY); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
// QR code — clean white card with a thin border, right-aligned in the box
if (qrDataUrl) {
const cardSize = qrSize + qrPad * 2;
const cardX = pageWidth - margin - cardSize - 3;
const cardY = y + (boxHeight - cardSize) / 2;
const cardY = y + (cardH - cardSize) / 2;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(...LIGHT);
doc.setLineWidth(0.4);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
}
return y + boxHeight + 6;
return y + cardH + 10;
}
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
y += 7;
// ─── journey card ──────────────────────────────────────────────────────────
doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
doc.rect(margin, y, pageWidth - margin * 2, 40);
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number {
const cardW = pageWidth - margin * 2;
const routeH = 30;
const trainRowH = 9;
const cardH = routeH + trainRowH;
// Origin
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('FROM', margin + 5, y + 6);
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.origin.code, margin + 5, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(schedule.origin.name, margin + 5, y + 20);
doc.setFontSize(8); doc.setTextColor(...MED);
doc.text(schedule.origin.city, margin + 5, y + 25);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(margin, y, cardW, cardH, 3, 3, 'D');
const dep = new Date(schedule.departureAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
// Arrow
doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
const ax = pageWidth / 2, ay = y + 20;
doc.line(ax - 10, ay, ax + 10, ay);
doc.line(ax + 10, ay, ax + 7, ay - 2);
doc.line(ax + 10, ay, ax + 7, ay + 2);
// Destination
const dx = pageWidth - margin - 50;
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('TO', dx, y + 6);
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.destination.code, dx, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(schedule.destination.name, dx, y + 20);
doc.setFontSize(8); doc.setTextColor(...MED);
doc.text(schedule.destination.city, dx, y + 25);
const arr = new Date(schedule.arrivalAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
y += 47;
// Train info bar
doc.setFillColor(248, 248, 248);
doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('TRAIN', margin + 5, y + 5);
doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.trainNumber + (schedule.trainName ? `${schedule.trainName}` : ''), margin + 20, y + 9);
if (schedule.seatClass) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
if (legLabel) {
doc.setFillColor(...BRAND);
doc.roundedRect(margin + 6, y - 3, doc.getTextWidth(legLabel.toUpperCase()) + 7, 6, 3, 3, 'F');
doc.setTextColor(255, 255, 255); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
doc.text(legLabel.toUpperCase(), margin + 6 + (doc.getTextWidth(legLabel.toUpperCase()) + 7) / 2, y, { align: 'center', charSpace: 0.3 });
}
return y + 18;
const padX = 8;
const topY = y + (legLabel ? 12 : 8);
// Origin block
label(doc, 'From', margin + padX, topY);
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.origin.code, margin + padX, topY + 8);
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 13);
const dep = new Date(schedule.departureAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 20.5);
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 25);
// Destination block (right-aligned)
const dx = pageWidth - margin - padX;
label(doc, 'To', dx, topY, { align: 'right' });
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.destination.code, dx, topY + 8, { align: 'right' });
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 13, { align: 'right' });
const arr = new Date(schedule.arrivalAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 20.5, { align: 'right' });
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 25, { align: 'right' });
// Dashed route line with endpoint markers, connecting the two blocks
const lineY = topY + 8.5;
const lineX1 = margin + padX + 24;
const lineX2 = dx - 24;
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.5);
doc.setLineDashPattern([1, 1.2], 0);
doc.line(lineX1, lineY, lineX2, lineY);
doc.setLineDashPattern([], 0);
doc.setFillColor(...BRAND);
doc.circle(lineX1, lineY, 0.9, 'F');
doc.circle(lineX2, lineY, 0.9, 'F');
// Train info sub-row
const rowY = y + routeH;
hairline(doc, margin, rowY, margin + cardW);
doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text('TRAIN', margin + padX, rowY + 6, { charSpace: 0.3 });
doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 6);
if (schedule.seatClass) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 6, { align: 'right' });
}
return y + cardH + 8;
}
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text('PASSENGER DETAILS', margin, y);
// ─── passenger details ─────────────────────────────────────────────────────
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number, pageWidth: number): number {
label(doc, 'Passenger details', margin, y);
y += 7;
const rows: [string, string][] = [
['Full Name', data.passengerName || '—'],
['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
['Full name', data.passengerName || '—'],
['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
['Nationality', data.nationality || '—'],
];
if (data.isRoundTrip) {
rows.push(['Outbound Seat', data.outboundSeatNumber || '—']);
rows.push(['Return Seat', data.inboundSeatNumber || '—']);
rows.push(['Outbound seat', data.outboundSeatNumber || '—']);
rows.push(['Return seat', data.inboundSeatNumber || '—']);
} else {
rows.push(['Seat', data.seatNumber || '—']);
}
autoTable(doc, {
startY: y,
body: rows,
theme: 'plain',
styles: { fontSize: 9, cellPadding: 3 },
columnStyles: {
0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
1: { textColor: [DARK[0], DARK[1], DARK[2]] },
},
alternateRowStyles: { fillColor: [248, 248, 248] },
margin: { left: margin, right: margin },
const rowH = 8;
rows.forEach(([k, v], i) => {
const rowY = y + i * rowH;
doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text(k.toUpperCase(), margin, rowY + 5, { charSpace: 0.2 });
doc.setFontSize(9.5); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
doc.text(v, pageWidth - margin, rowY + 5, { align: 'right' });
if (i < rows.length - 1) hairline(doc, margin, rowY + rowH, pageWidth - margin);
});
return (doc as any).lastAutoTable.finalY + 8;
return y + rows.length * rowH + 6;
}
// ─── fare summary ──────────────────────────────────────────────────────────
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(248, 248, 248);
doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('Fare', margin + 5, y + 7);
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
doc.text('✓ PAID', margin + 5, y + 15);
return y + 26;
const cardH = 20;
doc.setFillColor(...BRAND_SOFT);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
const padX = 7;
label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY });
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
doc.text('✓ PAID', margin + padX, y + 15);
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
return y + cardH + 8;
}
// ─── instructions ──────────────────────────────────────────────────────────
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(252, 211, 77);
doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
const cardH = 17;
const barW = 1.4;
doc.setFillColor(...AMBER_FILL);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 2, 2, 'F');
doc.setFillColor(...AMBER_BORDER);
doc.rect(margin, y, barW, cardH, 'F');
const padX = 6;
doc.setFontSize(8); doc.setTextColor(...AMBER_TEXT); doc.setFont('helvetica', 'bold');
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text('Present this voucher at the terminal for boarding', margin + 5, y + 11);
doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
return y + 24;
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
return y + cardH + 6;
}
// ─── footer ────────────────────────────────────────────────────────────────
function drawFooter(doc: jsPDF, createdAt: string): void {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const footerY = pageHeight - 22;
const footerY = pageHeight - 20;
doc.setDrawColor(...LIGHT);
doc.line(15, footerY, pageWidth - 15, footerY);
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
doc.setFontSize(7);
doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
doc.setFontSize(6.5);
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
}
// ─── public API ──────────────────────────────────────────────────────────────
@@ -295,25 +362,22 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const margin = 15;
const margin = PAGE_MARGIN;
let y = await drawHeader(doc, margin);
const qrDataUrl = await generateTicketQrDataUrl(data);
// Title
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' });
y += 10;
y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW);
y = drawStatusBadge(doc, data.status, y, pageW);
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW);
label(doc, 'Journey details', margin, y);
y += 7;
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
if (data.isRoundTrip && data.inboundSchedule) {
y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
}
y = drawPassengerDetails(doc, data, y, margin);
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt);

View File

@@ -0,0 +1,24 @@
// Payment gateway return URLs (Telebirr/Waafi/D-Money success & failure pages) are fixed,
// app-wide URLs configured once in the payment provider — they can't carry a per-request
// query param telling the return page which flow initiated payment. The normal
// results -> seats -> review -> payment flow always ends at /booking/confirmation, which
// reads its data from useBookingStore. But paying for an existing booking from the
// Manage Booking page (/booking/detail) doesn't populate that store, so returning to
// /booking/confirmation there would render blank/broken.
//
// This marker records "the last payment was initiated from Manage Booking for booking
// ref X" right before redirecting to the gateway, so the return page can send the user
// back to that booking's detail view instead. It's consumed (read + cleared) exactly once.
const STORAGE_KEY = 'edr_manage_booking_payment_ref';
export function markManageBookingPaymentReturn(bookingRef: string) {
if (typeof window === 'undefined' || !bookingRef) return;
localStorage.setItem(STORAGE_KEY, bookingRef);
}
export function consumeManageBookingPaymentReturn(): string | null {
if (typeof window === 'undefined') return null;
const ref = localStorage.getItem(STORAGE_KEY);
if (ref) localStorage.removeItem(STORAGE_KEY);
return ref;
}

View File

@@ -352,7 +352,7 @@ export class TelebirrProvider implements PaymentProvider {
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
}
private get privateKey(): string {
return `-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/ZcoOng1sJZ4CegopQVCw3HYqqVRLEudgT+dDpS8fRVy7zBgqZunju2VRCQuHeWs7yWgc9QGd4/8kRSLY+jlvKNeZ60yWcqEY+eKyQMmcjOz2Sn41fcVNgF+HV3DGiV4b23B6BCMjnpEFIb9d99/TsjsFSc7gCPgfl2yWDxE/Y1B2tVE6op2qd63YsMVFQGdre/CQYvFJENpQaBLMq4hHyBDgluUXlF0uA1X7UM0ZjbFC6ZIB/Hn1+pl5Ua8dKYrkVaecolmJT/s7c/+/1JeN+ja8luBoONsoODt2mTeVJHLF9Y3oh5rI+IY8HukIZJ1U6O7/JcjH3aRJTZagXUS9AgMBAAECggEBALBIBx8JcWFfEDZFwuAWeUQ7+VX3mVx/770kOuNx24HYt718D/HV0avfKETHqOfA7AQnz42EF1Yd7Rux1ZO0e3unSVRJhMO4linT1XjJ9ScMISAColWQHk3wY4va/FLPqG7N4L1w3BBtdjIc0A2zRGLNcFDBlxl/CVDHfcqD3CXdLukm/friX6TvnrbTyfAFicYgu0+UtDvfxTL3pRL3u3WTkDvnFK5YXhoazLctNOFrNiiIpCW6dJ7WRYRXuXhz7C0rENHyBtJ0zura1WD5oDbRZ8ON4v1KV4QofWiTFXJpbDgZdEeJJmFmt5HIi+Ny3P5n31WwZpRMHGeHrV23//0CgYEA+2/gYjYWOW3JgMDLX7r8fGPTo1ljkOUHuH98H/a/lE3wnnKKx+2ngRNZX4RfvNG4LLeWTz9plxR2RAqqOTbX8fj/NA/sS4mru9zvzMY1925FcX3WsWKBgKlLryl0vPScq4ejMLSCmypGz4VgLMYZqT4NYIkU2Lo1G1MiDoLy0CcCgYEAwt77exynUhM7AlyjhAA2wSINXLKsdFFF1u976x9kVhOfmbAutfMJPEQWb2WXaOJQMvMpgg2rU5aVsyEcuHsRH/2zatrxrGqLqgxaiqPz4ELINIh1iYK/hdRpr1vATHoebOv1wt8/9qxITNKtQTgQbqYci3KV1lPsOrBAB5S57nsCgYAvw+cagS/jpQmcngOEoh8I+mXgKEET64517DIGWHe4kr3dO+FFbc5eZPCbhqgxVJ3qUM4LK/7BJq/46RXBXLvVSfohR80Z5INtYuFjQ1xJLveeQcuhUxdK+95W3kdBBi8lHtVPkVsmYvekwK+ukcuaLSGZbzE4otcn47kajKHYDQKBgDbQyIbJ+ZsRw8CXVHu2H7DWJlIUBIS3s+CQ/xeVfgDkhjmSIKGX2to0AOeW+S9MseiTE/L8a1wY+MUppE2UeK26DLUbH24zjlPoI7PqCJjl0DFOzVlACSXZKV1lfsNEeriC61/EstZtgezyOkAlSCIH4fGr6tAeTU349Bnt0RtvAoGBAObgxjeH6JGpdLz1BbMj8xUHuYQkbxNeIPhH29CySn0vfhwg9VxAtIoOhvZeCfnsCRTj9OZjepCeUqDiDSoFznglrKhfeKUndHjvg+9kiae92iI6qJudPCHMNwP8wMSphkxUqnXFR3lr9A765GA980818UWZdrhrjLKtIIZdh+X1\n-----END PRIVATE KEY-----`
return this.config.get<string>("telebirr.privateKey") ?? "";
}
private get publicKey(): string {
return this.config.get<string>("telebirr.publicKey") ?? "";

View File

@@ -1,5 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./support-chat";
export enum TicketStatus {
Reserved = "RESERVED",
Confirmed = "CONFIRMED",

View File

@@ -0,0 +1,103 @@
/**
* Shared contracts for the passenger in-app customer-support chat.
*
* Unlike freight (company-scoped), a passenger conversation ("ticket") belongs to a
* single **individual passenger** (keyed by their IAM user id). Backoffice agents
* work a shared inbox (no assignment). Messages are text-only for the MVP.
*
* These are the wire (JSON) shapes — dates are ISO strings — plus the frozen
* Socket.IO event/namespace constants shared by the gateway (emitter) and both
* passenger web apps (subscribers).
*/
/** Lifecycle of a support conversation. Mirrors the Prisma `SupportConversationStatus`. */
export enum PassengerSupportStatus {
OPEN = "OPEN",
RESOLVED = "RESOLVED",
CLOSED = "CLOSED",
}
/** Author of a message. The Prisma `SupportSender` also has `BOT` (legacy, unused here). */
export enum PassengerSupportSender {
USER = "USER",
AGENT = "AGENT",
}
/** A single chat message on the wire. */
export interface PassengerSupportMessageDto {
id: string;
conversationId: string;
sender: PassengerSupportSender;
/** Display name of the author, best-effort. */
authorName?: string | null;
text: string;
createdAt: string;
}
/** A conversation ("ticket") on the wire, with denormalized last-message fields. */
export interface PassengerSupportConversationDto {
id: string;
/** Set for authenticated passengers; null for guest conversations. */
userId?: string | null;
/** Set for guest (unauthenticated) conversations. */
guestId?: string | null;
guestEmail?: string | null;
guestPhone?: string | null;
passengerId?: string | null;
/** Display name — passenger's name for authed, guest's name for guests. */
passengerName?: string | null;
subject?: string | null;
status: PassengerSupportStatus;
assignedAgentId?: string | null;
lastMessageAt?: string | null;
lastMessagePreview?: string | null;
lastMessageSender?: PassengerSupportSender | null;
/** Unread count for the caller's side (messages from the other sender after their cursor). */
unreadCount: number;
createdAt: string;
updatedAt: string;
}
/** Customer opens a new ticket: subject + first message. */
export interface CreatePassengerSupportConversationDto {
subject: string;
initialMessage: string;
}
/** Guest (unauthenticated) opens a ticket: identity + contact captured up front. */
export interface CreateGuestSupportConversationDto {
guestId: string;
name: string;
email: string;
phone?: string;
subject: string;
initialMessage: string;
}
/** Post a message into an existing conversation. */
export interface SendPassengerSupportMessageDto {
text: string;
}
/** Paginated list envelope for the conversations list endpoints. */
export interface PassengerSupportConversationListResult {
items: PassengerSupportConversationDto[];
count: number;
/** Total unread conversations for the caller's side (badge source). */
unreadCount: number;
}
/** Socket.io event names pushed server → client on the passenger support namespace. */
export const PASSENGER_SUPPORT_WS_EVENTS = {
MESSAGE_NEW: "passenger-support:message-new",
CONVERSATION_UPDATED: "passenger-support:conversation-updated",
} as const;
/** Socket.io namespace the passenger support gateway listens on. */
export const PASSENGER_SUPPORT_WS_NAMESPACE = "passenger-support-chat";
/** Payload for {@link PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW}. */
export interface PassengerSupportMessageEvent {
conversation: PassengerSupportConversationDto;
message: PassengerSupportMessageDto;
}

15
pnpm-lock.yaml generated
View File

@@ -501,6 +501,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.19
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/platform-socket.io':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
@@ -513,6 +516,9 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.1
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@nestjs/websockets':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -570,6 +576,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
socket.io:
specifier: ^4.8.3
version: 4.8.3
swagger-ui-express:
specifier: ^5.0.0
version: 5.0.1(express@4.22.2)
@@ -673,6 +682,9 @@ importers:
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zustand:
specifier: ^5.0.0
version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -758,6 +770,9 @@ importers:
react-hook-form:
specifier: ^7.51.0
version: 7.77.0(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zod:
specifier: ^3.22.4
version: 3.25.76