mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal file
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal 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;
|
||||
}
|
||||
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal file
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}¤cy=${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>
|
||||
|
||||
@@ -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`)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() },
|
||||
}),
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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") ?? "";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./support-chat";
|
||||
|
||||
export enum TicketStatus {
|
||||
Reserved = "RESERVED",
|
||||
Confirmed = "CONFIRMED",
|
||||
|
||||
103
packages/types/src/passenger/support-chat.ts
Normal file
103
packages/types/src/passenger/support-chat.ts
Normal 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
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user