diff --git a/.gitignore b/.gitignore index f03097e83..8eea260e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ coverage/ .DS_Store .idea/ .vscode/ -.npmrc \ No newline at end of file +.npmrc +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 4b9abe2d0..32cf04322 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -30,7 +30,6 @@ "@nestjs/core": "^11.1.19", "@nestjs/event-emitter": "^2.0.4", "@nestjs/jwt": "^10.2.0", - "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", @@ -46,9 +45,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^17.4.2", + "express": "^4.18.2", "jose": "^5.10.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", "pg": "^8.21.0", "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", @@ -64,9 +62,9 @@ "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@types/bcrypt": "^5.0.2", + "@types/express": "^4.17.21", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", - "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "jest": "^29.7.0", diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 0ce2f3e40..6267d04a9 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -439,6 +439,7 @@ model Seat { coach Coach @relation(fields: [coachId], references: [id]) bookingSeats BookingSeat[] blocks SeatBlock[] + ticketSeats TicketSeat[] @@unique([coachId, row, col]) @@unique([coachId, seatNumber]) @@ -629,6 +630,20 @@ model Ticket { validatorId String? booking Booking @relation(fields: [bookingId], references: [id]) validationLogs GateValidationLog[] + seats TicketSeat[] + + @@schema("passenger") +} + +model TicketSeat { + id String @id @default(uuid()) + ticketId String + seatId String + seatIndex Int @default(0) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + seat Seat @relation(fields: [seatId], references: [id]) + @@index([ticketId]) + @@index([seatId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/prisma/seed-complete.ts b/apps/edr-passenger-api/prisma/seed-complete.ts index b5f66cfc0..f9cc064be 100644 --- a/apps/edr-passenger-api/prisma/seed-complete.ts +++ b/apps/edr-passenger-api/prisma/seed-complete.ts @@ -113,8 +113,19 @@ async function main() { const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } }); if (existingSchedules.length > 0) { const scheduleIds = existingSchedules.map(s => s.id); - // Delete in correct order to avoid foreign key constraints - await prisma.bookingSeat.deleteMany({ where: { booking: { scheduleId: { in: scheduleIds } } } }); + const bookingIds = ( + await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } }) + ).map(b => b.id); + // Delete booking children in FK-safe order before deleting the bookings themselves + await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } }); + await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } }); await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 270dae8eb..5db52f80c 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -245,6 +245,7 @@ Payment providers send notifications to: .addTag("Support", "FAQ management and live chat support") .addTag("Tickets", "QR ticket generation, PDFs, and gate validation") .addTag("Wallet", "Wallet balance, top-ups, and transaction ledger") + .addTag("Config", "System configuration and settings") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index e16b8978b..2db7876aa 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -14,6 +14,63 @@ export class BookingsController { private guestService: GuestBookingService, ) {} + @Get('my') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get logged-in user\'s booking history', + description: 'Returns all bookings for the authenticated user with schedule and payment details' + }) + @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number' }) + @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) + @ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' }) + getMyBookings( + @Req() req: any, + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const passengerId = req.user?.passengerId; + if (!passengerId) throw new Error('Passenger ID not found in token'); + return this.service.findByPassengerId(passengerId, { + search, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20 + }); + } + + @Get('by-device') + @ApiOperation({ + summary: 'Get bookings by device ID', + description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' + }) + @ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' }) + @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number' }) + @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) + @ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' }) + @ApiResponse({ status: 400, description: 'Device ID is required' }) + getByDevice( + @Query('deviceId') deviceId?: string, + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + if (!deviceId) throw new BadRequestException('Device ID is required'); + return this.service.findByDeviceId(deviceId, { + search, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20 + }); + } + @Get() @ApiOperation({ summary: 'List all bookings with filters (Admin/Agent)', @@ -108,6 +165,17 @@ export class BookingsController { return this.service.getByRef(ref); } + @Patch(':id') + @ApiOperation({ + summary: 'Update booking details', + description: 'Updates booking information for admin/agent operations' + }) + @ApiResponse({ status: 200, description: 'Booking updated successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + update(@Param('id') id: string, @Body() dto: any) { + return this.service.update(id, dto); + } + @Patch(':bookingRef/modify') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -121,6 +189,28 @@ export class BookingsController { return this.service.modify(dto); } + @Delete(':id') + @ApiOperation({ + summary: 'Delete booking (admin only)', + description: 'Permanently deletes a booking record' + }) + @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + delete(@Param('id') id: string) { + return this.service.delete(id); + } + + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if booking is in use', + description: 'Returns list of modules/data that reference this booking' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkBookingUsage(id); + } + @Delete(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2b8b99740..1fd74de9f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -38,6 +38,147 @@ export class BookingsService { private currencyService: CurrencyService, ) {} + async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { + const { search, status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + + const where: any = { passengerId }; + + if (search) { + where.OR = [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, + { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, + ]; + } + + if (status) { + where.status = status; + } + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where }), + ]); + + return { + items: items.map(booking => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + arrivalAt: booking.schedule.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + })), + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async findByDeviceId(deviceId: string, filters: BookingFilters = {}) { + const { search, status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + + // Find user with this device ID + const device = await this.prisma.device.findUnique({ + where: { id: deviceId }, + include: { user: { include: { passenger: true } } }, + }).catch(() => null); + + const searchConditions = search ? [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, + { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, + ] : []; + + const where: any = { + OR: [ + { userAgent: deviceId }, + ...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []), + ], + }; + + if (search) { + where.AND = [{ OR: searchConditions }]; + } + + if (status) { + where.status = status; + } + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where }), + ]); + + return { + items: items.map(booking => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + arrivalAt: booking.schedule.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + })), + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + async findAll(filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; @@ -154,7 +295,6 @@ export class BookingsService { passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - // Use first passenger's nationality for fare lookup (or allow per-passenger pricing) const primaryNationality = passengersData[0]?.nationality; const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); const adultFareMinor = baseFareMinor * adultCount; @@ -302,6 +442,60 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; } + async update(id: string, dto: any) { + const booking = await this.prisma.booking.findUnique({ where: { id } }); + if (!booking) throw new NotFoundException('Booking not found'); + return this.prisma.booking.update({ + where: { id }, + data: { + status: dto.status || booking.status, + totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor, + displayCurrency: dto.displayCurrency || booking.displayCurrency, + displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor, + }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }); + } + + async delete(id: string) { + const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); + if (!booking) throw new NotFoundException('Booking not found'); + + await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); + await this.prisma.booking.delete({ where: { id } }); + + return { deleted: true, bookingRef: booking.bookingRef }; + } + + async checkBookingUsage(id: string) { + const booking = await this.prisma.booking.findUnique({ where: { id } }); + if (!booking) throw new NotFoundException('Booking not found'); + + const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([ + this.prisma.ticket.count({ where: { bookingId: id } }), + this.prisma.paymentIntent.count({ where: { bookingId: id } }), + this.prisma.bookingModification.count({ where: { bookingId: id } }), + this.prisma.bookingCancellation.count({ where: { bookingId: id } }), + ]); + + const usage = []; + if (ticketCount > 0) usage.push('Ticket(s)'); + if (paymentIntentCount > 0) usage.push('Payment record(s)'); + if (modificationsCount > 0) usage.push('Modification history'); + if (cancellationCount > 0) usage.push('Cancellation record(s)'); + + return { + isInUse: usage.length > 0, + affectedModules: usage, + }; + } + @Cron(CronExpression.EVERY_MINUTE) async expirePendingBookings() { const cutoff = new Date(Date.now() - 20 * 60 * 1000); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 1214acebc..93cbda7b0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -168,12 +168,19 @@ export class GuestBookingService { throw new BadRequestException('Email already registered. Please login instead.'); } + let accountPhone = firstPassenger.phone || null; + if (accountPhone) { + const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); + if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); + } + if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const passwordHash = await bcrypt.hash(dto.password, 10); const user = await this.prisma.user.create({ data: { fullName: firstPassenger.passengerName, email: firstPassenger.email, - phone: firstPassenger.phone || '', + phone: accountPhone, passwordHash, nationality: firstPassenger.nationality, nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, @@ -201,11 +208,19 @@ export class GuestBookingService { } } + // Use a guaranteed-unique guest phone to avoid constraint collisions + let guestPhone = firstPassenger.phone || null; + if (guestPhone) { + const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); + if (existingPhone) guestPhone = null; + } + if (!guestPhone) guestPhone = `+guest-${uniqueId}`; + const tempUser = await this.prisma.user.create({ data: { fullName: firstPassenger.passengerName, email: guestEmail, - phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`, + phone: guestPhone, passwordHash: await bcrypt.hash(Math.random().toString(36), 10), role: 'PASSENGER', }, @@ -235,6 +250,7 @@ export class GuestBookingService { displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', + userAgent: dto.deviceId, // contactEmail: firstPassenger.email, // Temporarily disabled until migration // contactPhone: firstPassenger.phone, // Temporarily disabled until migration seats: { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts index 25718cc8f..4b6625c75 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts @@ -1,12 +1,17 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; import { FareEngineService } from './fare-engine.service'; import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto'; +import { FaydaConfig } from '../../config/fayda.config'; @ApiTags('Fare Engine') @Controller('fare-engine') export class FareEngineController { - constructor(private service: FareEngineService) {} + constructor( + private service: FareEngineService, + private configService: ConfigService, + ) {} @Post('calculate') @ApiOperation({ @@ -63,3 +68,36 @@ Returns a full breakdown including a human-readable calculation trace.`, ); } } + +@ApiTags('Config') +@Controller('config') +export class ConfigController { + constructor(private configService: ConfigService) {} + + @Get('fayda-status') + @ApiOperation({ + summary: 'Check Verifayda 2.0 configuration status', + description: 'Returns whether Verifayda integration is enabled and ready to use' + }) + @ApiResponse({ + status: 200, + description: 'Verifayda status retrieved successfully', + schema: { + example: { + enabled: true, + mode: 'production', + apiUrl: 'https://api.verifayda.gov.et/v2' + } + } + }) + getFaydaStatus() { + const faydaConfig = this.configService.get('fayda'); + const verifaydaEnabled = this.configService.get('VERIFAYDA_ENABLED', false); + + return { + enabled: faydaConfig?.enabled || verifaydaEnabled, + mode: verifaydaEnabled ? 'production' : 'development', + apiUrl: this.configService.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'), + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts index fefdfb9a9..975db4584 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { FareEngineController } from './fare-engine.controller'; +import { FareEngineController, ConfigController } from './fare-engine.controller'; import { FareEngineService } from './fare-engine.service'; import { CurrencyController } from './currency.controller'; import { CurrencyModule } from '../currency/currency.module'; @Module({ imports: [CurrencyModule], - controllers: [FareEngineController, CurrencyController], + controllers: [FareEngineController, CurrencyController, ConfigController], providers: [FareEngineService], exports: [FareEngineService], }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 0fffb9d20..e26fb2211 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -22,6 +22,14 @@ export class FleetController { @ApiResponse({ status: 201, description: 'Train created' }) createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); } + @Patch('trains/:id') + @ApiOperation({ summary: 'Update a train service' }) + @ApiParam({ name: 'id', description: 'Train UUID' }) + @ApiBody({ type: CreateTrainDto }) + @ApiResponse({ status: 200, description: 'Train updated' }) + @ApiResponse({ status: 404, description: 'Train not found' }) + updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); } + @Get('coaches') @ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' }) @ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 317af9db5..daea3a9dd 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -112,6 +112,12 @@ export class FleetService { createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } + async updateTrain(id: string, dto: CreateTrainDto) { + const train = await this.prisma.train.findUnique({ where: { id } }); + if (!train) throw new NotFoundException('Train not found'); + return this.prisma.train.update({ where: { id }, data: dto }); + } + async getCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index e1fddbe47..c9a7bca54 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; +import { PrismaService } from '../../common/prisma.service'; @ApiTags('Passengers') @Controller('passengers') @@ -12,6 +13,7 @@ export class PassengersController { constructor( private service: PassengersService, private verifaydaService: VerifaydaService, + private prisma: PrismaService, ) {} @Get() @@ -37,6 +39,42 @@ export class PassengersController { }); } + @Get('me') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get current passenger profile', + description: 'Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.' + }) + @ApiResponse({ + status: 200, + description: 'Passenger profile retrieved successfully or null if not found' + }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + async getMe(@Request() req: any) { + if (!req.user || !req.user.userId) { + throw new UnauthorizedException('User not authenticated'); + } + + try { + const user = await this.prisma.user.findUnique({ + where: { id: req.user.userId }, + include: { + passenger: true, + }, + }); + + if (!user || !user.passenger) { + return null; + } + + return this.service.getProfile(user.passenger.id); + } catch (error) { + // If profile lookup fails for any reason, return null to allow app to continue + return null; + } + } + @Get(':id/profile') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -313,4 +351,37 @@ Returns saved passenger details with generated IDs and confirmation.`, getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); } + + @Patch(':id') + @ApiOperation({ + summary: 'Update passenger details', + description: 'Updates passenger information for admin/agent operations' + }) + @ApiResponse({ status: 200, description: 'Passenger updated successfully' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + updatePassenger(@Param('id') id: string, @Body() dto: any) { + return this.service.updatePassenger(id, dto); + } + + @Delete(':id') + @ApiOperation({ + summary: 'Delete passenger (admin only)', + description: 'Permanently deletes a passenger record and associated data' + }) + @ApiResponse({ status: 200, description: 'Passenger deleted successfully' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + deletePassenger(@Param('id') id: string) { + return this.service.deletePassenger(id); + } + + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if passenger is in use', + description: 'Returns list of modules/data that reference this passenger' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkPassengerUsage(id); + } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts index 756eb116b..cc7748457 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts @@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios'; import { PassengersController } from './passengers.controller'; import { PassengersService } from './passengers.service'; import { VerifaydaModule } from '../verifayda/verifayda.module'; +import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [VerifaydaModule, HttpModule], + imports: [VerifaydaModule, HttpModule, PrismaModule], controllers: [PassengersController], providers: [PassengersService] }) diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index b99ced226..8864a6363 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -180,6 +180,28 @@ export class PassengersService { getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } + async updatePassenger(id: string, dto: any) { + const passenger = await this.prisma.passenger.findUnique({ where: { id } }); + if (!passenger) throw new NotFoundException('Passenger not found'); + return this.prisma.passenger.update({ + where: { id }, + data: { + user: { + update: { + fullName: dto.fullName || undefined, + email: dto.email || undefined, + phone: dto.phone || undefined, + nationality: dto.nationality || undefined, + }, + }, + }, + include: { + user: { select: { fullName: true, email: true, phone: true, nationality: true } }, + loyalty: true, + }, + }); + } + async registerPassenger(dto: RegisterPassengerDto) { const isEthiopian = !!dto.nationalId; const isLoggedIn = !!dto.userId; @@ -270,4 +292,30 @@ export class PassengersService { message: 'Passenger details saved for guest booking', }; } + + async deletePassenger(id: string) { + const passenger = await this.prisma.passenger.findUnique({ where: { id } }); + if (!passenger) throw new NotFoundException('Passenger not found'); + + await this.prisma.passenger.delete({ where: { id } }); + return { deleted: true, passengerId: id }; + } + + async checkPassengerUsage(id: string) { + const [bookingCount, loyaltyAccount, walletAccount] = await Promise.all([ + this.prisma.booking.count({ where: { passengerId: id } }), + this.prisma.loyaltyAccount.findUnique({ where: { passengerId: id } }), + this.prisma.walletAccount.findUnique({ where: { passengerId: id } }), + ]); + + const usage = []; + if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`); + if (loyaltyAccount) usage.push('Loyalty account'); + if (walletAccount) usage.push('Wallet account'); + + return { + isInUse: usage.length > 0, + affectedModules: usage, + }; + } } \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 3ff1fee32..49b570ab1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger'; +import { Response } from 'express'; import { PaymentsService } from './payments.service'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto'; +import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { RolesGuard } from '../../common/roles.guard'; import { Roles } from '../../common/roles.decorator'; @@ -62,4 +63,113 @@ export class PaymentsController { @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } + + @Get('checkout') + @ApiOperation({ + summary: 'Browser checkout redirect', + description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.', + }) + @ApiQuery({ name: 'bookingId', required: true }) + @ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false }) + @ApiProduces('text/html') + async checkout( + @Query('bookingId') bookingId: string, + @Query('method') method: PaymentMethodTypeEnum, + @Query('platform') platform: PaymentPlatformDto = 'web', + @Res() res: Response, + ) { + if (!bookingId) { + return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId')); + } + if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { + return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method')); + } + + try { + const result = await this.service.initiatePayment({ bookingId, method, platform }); + const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined; + + if (url) { + return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url)); + } + + return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'An unexpected error occurred'; + return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message)); + } + } + + private buildRedirectHtml(url: string): string { + const escaped = url.replace(/"/g, '"'); + return ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 661c0819e..aee99ed4b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -182,8 +182,6 @@ export class PaymentsService { return this.formatIntentResponse(intent); } - - private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { @@ -349,9 +347,31 @@ export class PaymentsService { }); }); - await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); - await this.ticketsService.generate(booking.id); - await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + try { + await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); + } catch (err) { + this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`); + } + + try { + await this.createJourneySegments(booking); + } catch (err) { + this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`); + } + + try { + await this.ticketsService.generate(booking.id); + } catch (err) { + this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } + + try { + await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + } catch (err) { + this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`); + } + this.eventEmitter.emit('payment.succeeded', { booking }); return { alreadyFinalized: false }; } @@ -390,4 +410,47 @@ export class PaymentsService { await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); } + + private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: booking.scheduleId }, + include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }); + if (!schedule) return; + + const stopTimes = schedule.stopTimes; + if (stopTimes.length < 2) return; + + const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId); + const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId); + + if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return; + + const journey = await this.prisma.journey.create({ + data: { + passengerId: booking.passengerId, + status: 'CONFIRMED', + totalMinor: booking.totalMinor, + currency: booking.currency, + }, + }); + + const journeySegments = []; + for (const bookingSeat of booking.seats) { + for (let i = originSequence; i < destSequence; i++) { + journeySegments.push({ + journeyId: journey.id, + scheduleId: booking.scheduleId, + segmentOrder: i, + seatId: bookingSeat.seatId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + } + + if (journeySegments.length > 0) { + await this.prisma.journeySegment.createMany({ data: journeySegments }); + } + } } diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts index 9984b5842..da9e74054 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider { merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, trade_type: 'Checkout' as const, - title: `EDR Booking ${input.bookingRef}`, + title: `EDR Booking`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 6abd4e11a..b1b1a3af3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -353,9 +353,13 @@ export class SeatsService { // No-op for status — availability is segment-scoped via JourneySegment // seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag } + async releaseSeats(seatIds: string[]) { - // Only reset seats that are physically BLOCKED back to AVAILABLE if needed - // For segment-based bookings, releasing is handled by JourneySegment deletion + if (seatIds.length > 0) { + await this.prisma.journeySegment.deleteMany({ + where: { seatId: { in: seatIds } }, + }); + } } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise { @@ -480,6 +484,16 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); - for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); } + for (const hold of expired) { + await this.releaseSeats(hold.seatIds); + try { + await this.prisma.seatHold.delete({ where: { id: hold.id } }); + } catch (err) { + // Ignore if already deleted (e.g., by another process) + if (err instanceof Error && !err.message.includes('P2025')) { + throw err; + } + } + } } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cb9aeeb76..5711355e0 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,16 +1,52 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Tickets') @Controller('tickets') -@UseGuards(JwtGuard) -@ApiBearerAuth('JWT-auth') export class TicketsController { constructor(private service: TicketsService) {} + @Post('generate/:bookingId') + @ApiOperation({ + summary: 'Generate ticket for booking (confirmation page)', + description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' + }) + generateTicket(@Param('bookingId') bookingId: string) { + return this.service.generate(bookingId); + } + + @Patch('update-seats/:bookingId') + @ApiOperation({ + summary: 'Update ticket seats before final confirmation', + description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.' + }) + updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) { + return this.service.updateSeats(bookingId, body.seatIds); + } + + @Get() + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all tickets with optional filters' }) + listTickets( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('skip') skip?: string, + @Query('take') take?: string, + ) { + return this.service.listTickets({ + search, + status, + skip: skip ? parseInt(skip) : 0, + take: take ? parseInt(take) : 50, + }); + } + @Get(':bookingRef') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get ticket with QR code and passenger details', description: `Returns ticket information including: @@ -26,6 +62,8 @@ export class TicketsController { } @Post(':bookingRef/validate') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.' @@ -39,20 +77,37 @@ export class TicketsController { } @Get(':ticketId/validation-logs') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get validation logs for ticket' }) getValidationLogs(@Param('ticketId') ticketId: string) { return this.service.getValidationLogs(ticketId); } @Get('offline/export') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export tickets for offline validation' }) exportOfflineData(@Query('scheduleId') scheduleId: string) { return this.service.exportOfflineData(scheduleId); } @Post('validate/offline') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Batch import offline validations' }) validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } + + @Delete(':id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Delete ticket (admin only)', + description: 'Permanently deletes a ticket record and removes associated seat blocks' + }) + delete(@Param('id') id: string) { + return this.service.delete(id); + } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index f1c01ab9d..cd02974d3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -1,6 +1,13 @@ import { Module } from '@nestjs/common'; import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; +import { JwtGuard } from '../../common/jwt.guard'; -@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] }) +@Module({ + controllers: [TicketsController], + providers: [TicketsService, JwtGuard], + exports: [TicketsService, JwtGuard], +}) export class TicketsModule {} + +export { TicketsController } from './tickets.controller'; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 33971d3ec..71b65899c 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -13,19 +13,142 @@ interface OfflineValidation { export class TicketsService { constructor(private prisma: PrismaService) {} + async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { + const where: any = {}; + if (filters.search) { + where.OR = [ + { bookingRef: { contains: filters.search, mode: 'insensitive' } }, + { barcodePayload: { contains: filters.search, mode: 'insensitive' } }, + { booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } }, + ]; + } + if (filters.status) { + where.booking = { status: filters.status }; + } + const tickets = await this.prisma.ticket.findMany({ + where, + include: { + booking: { + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + passenger: { include: { user: true } }, + }, + }, + }, + skip: filters.skip, + take: filters.take, + orderBy: { issuedAt: 'desc' }, + }); + const total = await this.prisma.ticket.count({ where }); + return { + items: tickets.map((t) => ({ + id: t.id, + ticketNumber: t.barcodePayload, + bookingRef: t.bookingRef, + booking: { + bookingRef: t.booking.bookingRef, + status: t.booking.status, + passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, + contactEmail: t.booking.contactEmail, + }, + schedule: t.booking.schedule, + seat: t.booking.seats[0]?.seat, + status: t.booking.status, + validatedAt: t.validatedAt, + createdAt: t.issuedAt, + })), + total, + skip: filters.skip, + take: filters.take, + }; + } + async generate(bookingId: string) { + if (!bookingId) { + throw new BadRequestException('Booking ID is required'); + } + const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, - include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } } + }, }); - if (!booking) throw new NotFoundException('Booking not found'); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; - return this.prisma.ticket.upsert({ - where: { bookingId }, - update: { qrPayload, barcodePayload }, - create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload } + + const ticket = await this.prisma.ticket.upsert({ + where: { bookingId }, + update: { qrPayload, barcodePayload }, + create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, }); + + // Create permanent seat blocks for all booked seats + const seatIds = booking.seats.map(bs => bs.seatId); + for (const seatId of seatIds) { + await this.prisma.seatBlock.create({ + data: { + seatId, + reason: `Permanently booked in ticket ${ticket.id}`, + blockedBy: 'SYSTEM', + approvedBy: 'SYSTEM', + } + }).catch(() => null); // Ignore if already exists + } + + return ticket; + } + + async updateSeats(bookingId: string, newSeatIds: string[]) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, ticket: true }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (!booking.ticket) throw new BadRequestException('No ticket found for this booking'); + + // Remove old seat blocks + const oldSeatIds = booking.seats.map(bs => bs.seatId); + for (const seatId of oldSeatIds) { + await this.prisma.seatBlock.deleteMany({ + where: { + seatId, + reason: { contains: booking.ticket.id } + } + }); + } + + // Remove old booking seats + await this.prisma.bookingSeat.deleteMany({ where: { bookingId } }); + + // Create new seat blocks + for (const seatId of newSeatIds) { + await this.prisma.seatBlock.create({ + data: { + seatId, + reason: `Permanently booked in ticket ${booking.ticket.id}`, + blockedBy: 'SYSTEM', + approvedBy: 'SYSTEM', + } + }).catch(() => null); + } + + // Create new booking seats (placeholder with minimal data) + for (let i = 0; i < newSeatIds.length; i++) { + await this.prisma.bookingSeat.create({ + data: { + bookingId, + seatId: newSeatIds[i], + passengerName: `Passenger ${i + 1}`, + } + }); + } + + return { success: true, updatedSeats: newSeatIds.length }; } async getByRef(bookingRef: string) { @@ -147,4 +270,22 @@ export class TicketsService { return results; } + + async delete(id: string) { + const ticket = await this.prisma.ticket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } }); + + // Remove seat blocks associated with this ticket + await this.prisma.seatBlock.deleteMany({ + where: { + reason: { contains: id } + } + }); + + await this.prisma.ticket.delete({ where: { id } }); + + return { deleted: true, ticketId: id }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 433499c62..910d933c4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,12 +2,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Filter, Download, Eye, XCircle } from 'lucide-react'; +import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; -import { bookingsApi } from '@/lib/api'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { BookingFilters } from '@/types'; @@ -18,6 +20,10 @@ export default function BookingsPage() { search: '', status: '', }); + const [selectedBooking, setSelectedBooking] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [bookingToDelete, setBookingToDelete] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); const queryClient = useQueryClient(); @@ -34,16 +40,46 @@ export default function BookingsPage() { mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookings'] }); - alert('Booking cancelled successfully'); + setSuccessMessage('Booking cancelled successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to cancel booking'}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookings'] }); + setDeleteConfirmOpen(false); + setBookingToDelete(null); + setSuccessMessage('Booking deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + setDeleteConfirmOpen(false); + alert(`Error: ${error.message || 'Failed to delete booking'}`); }, }); const handleCancel = async (booking: any) => { - if (confirm(`Are you sure you want to cancel booking ${booking.bookingRef}?`)) { + if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) { await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); } }; + const handleDeleteClick = (booking: any) => { + setBookingToDelete(booking); + setDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (bookingToDelete) { + await deleteMutation.mutateAsync(bookingToDelete.id); + } + }; + const columns = [ { key: 'bookingRef', @@ -94,13 +130,12 @@ export default function BookingsPage() { ]; const actions = [ - // TODO: Create booking detail page - // { - // label: 'View Details', - // onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, + { + label: 'View Details', + onClick: (booking: any) => setSelectedBooking(booking), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'Cancel Booking', onClick: handleCancel, @@ -108,6 +143,12 @@ export default function BookingsPage() { icon: XCircle, show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED', }, + { + label: 'Delete', + onClick: handleDeleteClick, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -121,6 +162,11 @@ export default function BookingsPage() {
+ {successMessage && ( +
+ ✓ {successMessage} +
+ )} {error && (
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'} @@ -166,6 +212,163 @@ export default function BookingsPage() { /> )}
+ + {/* Booking Details Modal */} + setSelectedBooking(null)} + title="Booking Details" + size="xl" + > + {selectedBooking && ( +
+ {/* Booking Information */} +
+
+ +

{selectedBooking.bookingRef}

+
+
+ +
+ + {selectedBooking.status} + +
+
+
+ +

{selectedBooking.bookingType || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.createdAt)}

+
+
+ +
+ + {/* Passenger Information */} +
+

Passenger Information

+
+
+ +

{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}

+
+
+ +

{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}

+
+
+ +

{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}

+
+
+ +

{selectedBooking.passengerId || 'N/A'}

+
+
+
+ +
+ + {/* Booking Details */} +
+

Journey Details

+
+
+ +

{selectedBooking.adultCount || 0}

+
+
+ +

{selectedBooking.childCount || 0}

+
+
+ +

{selectedBooking.scheduleId || 'N/A'}

+
+
+ +

{selectedBooking.promoCode || 'None'}

+
+
+
+ +
+ + {/* Payment Information */} +
+

Payment Information

+
+
+ +

{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}

+
+
+ +
+ + {selectedBooking.paymentIntent?.status || 'PENDING'} + +
+
+
+ +

{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}

+
+
+ +

{selectedBooking.displayCurrency || selectedBooking.currency}

+
+
+
+ +
+ + {/* Additional Information */} +
+

Additional Information

+
+
+ +

{selectedBooking.source || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.updatedAt)}

+
+
+
+ +
+ setSelectedBooking(null)} + > + Close + +
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + { + setDeleteConfirmOpen(false); + setBookingToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="Delete Booking" + message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} + confirmText="Delete" + cancelText="Cancel" + isLoading={deleteMutation.isPending} + isDanger={true} + />
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 8ae8dd642..2abe00723 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -6,12 +6,14 @@ import { fleetApi } from '@/lib/api'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react'; export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingCoach, setEditingCoach] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null }); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -65,9 +67,14 @@ export default function CoachesPage() { } }; - const handleDelete = async (coach: any) => { - if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) { - await deleteMutation.mutateAsync(coach.id); + const handleDelete = (coach: any) => { + setDeleteConfirm({ isOpen: true, coach }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.coach) { + await deleteMutation.mutateAsync(deleteConfirm.coach.id); + setDeleteConfirm({ isOpen: false, coach: null }); } }; @@ -197,6 +204,18 @@ export default function CoachesPage() { /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, coach: null })} + onConfirm={confirmDelete} + title="Delete Coach" + message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`} + confirmText="Delete" + isDanger={true} + warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */} - {loading ? 'Signing in...' : 'Sign In'} + {loading ? 'Signing in...' : 'Sign in'} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index e80de0fe6..2f47cdb7a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -1,14 +1,16 @@ 'use client'; import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { UserPlus, Download, Eye } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Download, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; -import { passengersApi } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { passengersApi, apiClient } from '@/lib/api'; +import { formatDate, formatDateTime } from '@/lib/utils'; import { PassengerFilters } from '@/types'; export default function PassengersPage() { @@ -17,6 +19,28 @@ export default function PassengersPage() { pageSize: 20, search: '', }); + const [selectedPassenger, setSelectedPassenger] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['passengers'] }); + }, + }); + + const handleDelete = (passenger: any) => { + setDeleteConfirm({ isOpen: true, passenger }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.passenger) { + await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + setDeleteConfirm({ isOpen: false, passenger: null }); + } + }; const { data, isLoading, error } = useQuery({ queryKey: ['passengers', filters], @@ -65,14 +89,19 @@ export default function PassengersPage() { }, ]; - const actions: any[] = [ - // TODO: Create passenger detail page - // { - // label: 'View Details', - // onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, + const actions = [ + { + label: 'View Details', + onClick: (passenger: any) => setSelectedPassenger(passenger), + variant: 'secondary' as const, + icon: Eye, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -130,6 +159,184 @@ export default function PassengersPage() { /> )} + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, passenger: null })} + onConfirm={confirmDelete} + title="Delete Passenger" + message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} + confirmText="Delete" + isDanger={true} + warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." + /> + + {/* Passenger Details Modal */} + setSelectedPassenger(null)} + title="Passenger Details" + size="xl" + > + {selectedPassenger && ( +
+ {/* Personal Information */} +
+

Personal Information

+
+
+ +

{selectedPassenger.fullName}

+
+
+ +

+ {selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} +

+
+
+ +

{selectedPassenger.gender || 'N/A'}

+
+
+ +

{selectedPassenger.nationality || 'N/A'}

+
+
+
+ +
+ + {/* Contact Information */} +
+

Contact Information

+
+
+ +

{selectedPassenger.email || 'N/A'}

+
+
+ +

{selectedPassenger.phone || 'N/A'}

+
+
+
+ +
+ + {/* Identification */} +
+

Identification

+
+
+ +

{selectedPassenger.nationalId || 'N/A'}

+
+
+ +

{selectedPassenger.passportNumber || 'N/A'}

+
+
+ +

{selectedPassenger.passportCountry || 'N/A'}

+
+
+ +
+ + {selectedPassenger.nationalId ? 'Verified' : 'Unverified'} + +
+
+
+
+ +
+ + {/* Account Information */} +
+

Account Information

+
+
+ +

{selectedPassenger.id}

+
+
+ +

{selectedPassenger.userId || 'N/A'}

+
+
+
+ + {/* Loyalty & Wallet (if available) */} + {(selectedPassenger.loyalty || selectedPassenger.wallet) && ( + <> +
+
+ {selectedPassenger.loyalty && ( +
+

Loyalty Account

+
+
+ +

{selectedPassenger.loyalty.tier || 'N/A'}

+
+
+ +

{selectedPassenger.loyalty.pointsBalance || 0}

+
+
+
+ )} + {selectedPassenger.wallet && ( +
+

Wallet

+
+
+ +

+ {(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency} +

+
+
+
+ )} +
+ + )} + +
+ + {/* Timestamps */} +
+

Timestamps

+
+
+ +

{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}

+
+
+ +

{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}

+
+
+
+ +
+ setSelectedPassenger(null)} + > + Close + +
+
+ )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index c555cd114..cc1638a1b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi } from '@/lib/api'; @@ -24,6 +25,7 @@ export default function RoutesPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(undefined); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null }); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -148,9 +150,14 @@ export default function RoutesPage() { return origin && dest ? `${origin.name} - ${dest.name}` : ''; }; - const handleDelete = async (route: any) => { - if (confirm(`Are you sure you want to delete ${route.name}?`)) { - await deleteMutation.mutateAsync(route.id); + const handleDelete = (route: any) => { + setDeleteConfirm({ isOpen: true, route }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.route) { + await deleteMutation.mutateAsync(deleteConfirm.route.id); + setDeleteConfirm({ isOpen: false, route: null }); } }; @@ -246,6 +253,18 @@ export default function RoutesPage() { emptyMessage="No routes found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, route: null })} + onConfirm={confirmDelete} + title="Delete Route" + message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */}
+ {editingRoute && ( +
+

⚠ Warning

+

Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx index 053b5f312..04af0f707 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx @@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { seatClassesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; @@ -14,6 +15,7 @@ export default function SeatClassesPage() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingSeatClass, setEditingSeatClass] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null }); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -63,9 +65,14 @@ export default function SeatClassesPage() { } }; - const handleDelete = async (seatClass: any) => { - if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) { - await deleteMutation.mutateAsync(seatClass.id); + const handleDelete = (seatClass: any) => { + setDeleteConfirm({ isOpen: true, seatClass }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.seatClass) { + await deleteMutation.mutateAsync(deleteConfirm.seatClass.id); + setDeleteConfirm({ isOpen: false, seatClass: null }); } }; @@ -98,7 +105,7 @@ export default function SeatClassesPage() {
-

Seat Classes

+

Classes

Manage seat class configurations

@@ -132,6 +139,18 @@ export default function SeatClassesPage() { emptyMessage="No seat classes found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, seatClass: null })} + onConfirm={confirmDelete} + title="Delete Seat Class" + message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments." + /> + {/* Add/Edit Modal */} (null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null }); const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ @@ -67,9 +69,14 @@ export default function StationsPage() { } }; - const handleDelete = async (station: any) => { - if (confirm(`Are you sure you want to delete ${station.name}?`)) { - await deleteMutation.mutateAsync(station.id); + const handleDelete = (station: any) => { + setDeleteConfirm({ isOpen: true, station }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.station) { + await deleteMutation.mutateAsync(deleteConfirm.station.id); + setDeleteConfirm({ isOpen: false, station: null }); } }; @@ -223,6 +230,18 @@ export default function StationsPage() { emptyMessage="No stations found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, station: null })} + onConfirm={confirmDelete} + title="Delete Station" + message={`Are you sure you want to delete ${deleteConfirm.station?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */} + {editingStation && ( +
+

⚠ Warning

+

Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 884fd5d8b..5bb7fccb2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -2,27 +2,35 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, RefreshCw, CheckCircle } from 'lucide-react'; +import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; -import { ticketsApi } from '@/lib/api'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { ticketsApi, apiClient } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; export default function TicketsPage() { const [filters, setFilters] = useState({ search: '', status: '' }); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [ticketToDelete, setTicketToDelete] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); const queryClient = useQueryClient(); - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters], - queryFn: () => ticketsApi.getAll(filters), + queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }), }); const regenerateMutation = useMutation({ mutationFn: ticketsApi.regenerate, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); - alert('Ticket regenerated successfully'); + setSuccessMessage('Ticket regenerated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to regenerate ticket'}`); }, }); @@ -30,12 +38,31 @@ export default function TicketsPage() { mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); - alert('Ticket validated successfully'); + setSuccessMessage('Ticket validated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to validate ticket'}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setDeleteConfirmOpen(false); + setTicketToDelete(null); + setSuccessMessage('Ticket deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + setDeleteConfirmOpen(false); + alert(`Error: ${error.message || 'Failed to delete ticket'}`); }, }); const handleRegenerate = async (ticket: any) => { - if (confirm(`Regenerate ticket ${ticket.ticketNumber}?`)) { + if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) { await regenerateMutation.mutateAsync(ticket.id); } }; @@ -47,6 +74,17 @@ export default function TicketsPage() { }); }; + const handleDeleteClick = (ticket: any) => { + setTicketToDelete(ticket); + setDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (ticketToDelete) { + await deleteMutation.mutateAsync(ticketToDelete.id); + } + }; + const columns = [ { key: 'ticketNumber', @@ -121,15 +159,8 @@ export default function TicketsPage() { ]; const actions = [ - // TODO: Create ticket detail page - // { - // label: 'View Details', - // onClick: (ticket: any) => window.location.href = `/tickets/${ticket.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, { - label: 'Validate', + label: 'Check-in', onClick: handleValidate, variant: 'primary' as const, icon: CheckCircle, @@ -141,6 +172,12 @@ export default function TicketsPage() { variant: 'secondary' as const, icon: RefreshCw, }, + { + label: 'Delete', + onClick: handleDeleteClick, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -155,6 +192,16 @@ export default function TicketsPage() { {/* Filters */}
+ {successMessage && ( +
+ ✓ {successMessage} +
+ )} + {error && ( +
+ Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'} +
+ )}
@@ -191,6 +238,22 @@ export default function TicketsPage() { loading={isLoading} emptyMessage="No tickets found" /> + + {/* Delete Confirmation Dialog */} + { + setDeleteConfirmOpen(false); + setTicketToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="Delete Ticket" + message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} + confirmText="Delete" + cancelText="Cancel" + isLoading={deleteMutation.isPending} + isDanger={true} + />
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 3b3b713b2..c6936f52f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -6,6 +6,7 @@ import { Plus, Edit, Trash2, Train } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Badge from '@/components/ui/Badge'; import { fleetApi } from '@/lib/api'; import { Train as TrainType } from '@/types'; @@ -14,6 +15,7 @@ import { formatDate } from '@/lib/utils'; export default function TrainsPage() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null }); const queryClient = useQueryClient(); @@ -28,6 +30,10 @@ export default function TrainsPage() { queryClient.invalidateQueries({ queryKey: ['trains'] }); setShowModal(false); setEditingTrain(null); + alert('Train created successfully'); + }, + onError: (error: any) => { + alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error')); }, }); @@ -37,9 +43,32 @@ export default function TrainsPage() { queryClient.invalidateQueries({ queryKey: ['trains'] }); setShowModal(false); setEditingTrain(null); + alert('Train updated successfully'); + }, + onError: (error: any) => { + alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error')); }, }); + const deleteTrainMutation = useMutation({ + mutationFn: (id: string) => fleetApi.deleteTrain(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trains'] }); + alert('Train deleted successfully'); + }, + }); + + const handleDelete = (train: TrainType) => { + setDeleteConfirm({ isOpen: true, train }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.train) { + await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); + setDeleteConfirm({ isOpen: false, train: null }); + } + }; + const handleSubmit = async (formData: FormData) => { const trainData = { number: formData.get('number') as string, @@ -113,6 +142,12 @@ export default function TrainsPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -142,6 +177,18 @@ export default function TrainsPage() { emptyMessage="No trains found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, train: null })} + onConfirm={confirmDelete} + title="Delete Train" + message={`Are you sure you want to delete train ${deleteConfirm.train?.number}?`} + confirmText="Delete" + isDanger={true} + warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." + /> + {/* Add/Edit Modal */} + {editingTrain && ( +
+

⚠ Warning

+

Editing this train may impact schedules and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index ec5791579..db462ca00 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -62,7 +62,7 @@ const navigationSections = [ { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, { name: 'Seats', href: '/seats', icon: Armchair }, { name: 'Schedules', href: '/schedules', icon: Calendar }, - { name: 'Seat Classes', href: '/seat-classes', icon: Settings }, + { name: 'Classes', href: '/seat-classes', icon: Settings }, ] }, { @@ -70,7 +70,6 @@ const navigationSections = [ items: [ { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign }, { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Wallet Management', href: '/wallet', icon: Wallet }, { name: 'Promotions', href: '/promotions', icon: Gift }, ] }, diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 000000000..ec84f0c9a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { AlertCircle, AlertTriangle } from 'lucide-react'; +import Modal from './Modal'; +import ActionButton from './ActionButton' + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + isLoading?: boolean; + isDanger?: boolean; + warning?: string; +} + +export default function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + isLoading = false, + isDanger = false, + warning, +}: ConfirmDialogProps) { + return ( + +
+
+ {isDanger && ( + + )} +

{message}

+
+ {warning && ( +
+ +
+

Warning

+

{warning}

+
+
+ )} +
+ + {cancelText} + + + {isLoading ? 'Processing...' : confirmText} + +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx index 8ba76825d..960bf345a 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx @@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }: return (
-
-
+
+

{title}

-
{children}
+
+ {children} +
); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 3e1f1e867..614fb3e1f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -1,6 +1,9 @@ import { apiClient } from '@/lib/api-client'; import { PaginatedResponse } from '@edr/types'; +// Export apiClient for direct use +export { apiClient }; + // Bookings API export const bookingsApi = { getAll: async (params?: any) => { @@ -15,8 +18,32 @@ export const bookingsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/bookings/${id}`), + getMy: async (params?: any) => { + const cleanParams = Object.fromEntries( + Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) + ) as Record; + const query = new URLSearchParams(cleanParams).toString(); + const response = await apiClient.get(`/bookings/my${query ? `?${query}` : ''}`); + if (response?.data) { + return Array.isArray(response.data) ? { items: response.data } : response; + } + return Array.isArray(response) ? { items: response } : response; + }, + getByDevice: async (deviceId: string, params?: any) => { + const cleanParams = Object.fromEntries( + Object.entries({ ...params }).filter(([_, value]) => value !== '' && value !== undefined && value !== null) + ) as Record; + cleanParams['deviceId'] = deviceId; + const query = new URLSearchParams(cleanParams).toString(); + const response = await apiClient.get(`/bookings/by-device${query ? `?${query}` : ''}`); + if (response?.data) { + return Array.isArray(response.data) ? { items: response.data } : response; + } + return Array.isArray(response) ? { items: response } : response; + }, cancel: (id: string, data?: any) => apiClient.post(`/bookings/${id}/cancel`, data), modify: (id: string, data: any) => apiClient.patch(`/bookings/${id}`, data), + checkUsage: (id: string) => apiClient.get(`/bookings/${id}/usage`), }; // Passengers API @@ -140,7 +167,10 @@ export const paymentsApi = { // Tickets API export const ticketsApi = { getAll: async (params?: any) => { - const query = new URLSearchParams(params as Record).toString(); + const cleanParams = Object.fromEntries( + Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) + ) as Record; + const query = new URLSearchParams(cleanParams).toString(); const response = await apiClient.get(`/tickets${query ? `?${query}` : ''}`); if (response?.data) { return Array.isArray(response.data) ? { items: response.data } : response; diff --git a/apps/edr-passenger-web/backoffice/src/lib/utils.ts b/apps/edr-passenger-web/backoffice/src/lib/utils.ts index 249ffcef2..fe472a6b0 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/utils.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/utils.ts @@ -8,16 +8,25 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string }).format(amount / 100); }; -export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { - return format(new Date(date), formatStr); +export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, formatStr); }; -export const formatDateTime = (date: string | Date): string => { - return format(new Date(date), 'MMM dd, yyyy HH:mm'); +export const formatDateTime = (date?: string | Date | null): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, 'MMM dd, yyyy HH:mm'); }; -export const formatDateTimeLocal = (date: string | Date): string => { - return format(new Date(date), 'MMM dd, yyyy HH:mm'); +export const formatDateTimeLocal = (date?: string | Date | null): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, 'MMM dd, yyyy HH:mm'); }; export const getStatusColor = (status: string): string => { diff --git a/apps/edr-passenger-web/backoffice/src/styles/globals.css b/apps/edr-passenger-web/backoffice/src/styles/globals.css index 9275fab64..1ad505b16 100644 --- a/apps/edr-passenger-web/backoffice/src/styles/globals.css +++ b/apps/edr-passenger-web/backoffice/src/styles/globals.css @@ -55,64 +55,136 @@ } body { - @apply bg-background text-foreground; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); } } @layer components { .card { - @apply bg-card text-card-foreground rounded-lg shadow-sm border border-border p-6; + background-color: hsl(var(--card)); + color: hsl(var(--card-foreground)); + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + border: 1px solid hsl(var(--border)); + padding: 1.5rem; } .btn { - @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-weight: 500; + transition-property: background-color; + transition-duration: 200ms; + display: inline-flex; + align-items: center; + justify-content: center; } .btn-primary { - @apply bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] dark:bg-[rgb(20,113,76)] dark:hover:bg-[rgb(16,90,61)] shadow-md; + background-color: rgb(20, 113, 76); + color: white; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + .btn-primary:hover { + background-color: rgb(16, 90, 61); } .btn-secondary { - @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + } + + .btn-secondary:hover { + background-color: hsl(var(--secondary) / 0.8); } .btn-danger { - @apply bg-[rgb(20,113,76)] text-destructive-foreground hover:bg-[rgb(16,90,61)]; + background-color: rgb(20, 113, 76); + color: hsl(var(--destructive-foreground)); + } + + .btn-danger:hover { + background-color: rgb(16, 90, 61); } .input { - @apply w-full px-3 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent; + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid hsl(var(--input)); + border-radius: 0.5rem; + background-color: hsl(var(--background)); + } + + .input:focus { + outline: none; + ring: 2px hsl(var(--ring)); + border-color: transparent; } .label { - @apply block text-sm font-medium text-foreground mb-1; + display: block; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 0.25rem; } .gradient-edr { - @apply bg-[rgb(20,113,76)]; + background-color: rgb(20, 113, 76); } .gradient-edr-bg { - @apply bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900; + background: linear-gradient(to bottom, #0f172a, #1e293b, #0f172a); } .edr-badge { - @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; } .edr-badge-success { - @apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400; + background-color: #dcfce7; + color: #166534; + } + + .dark .edr-badge-success { + background-color: rgb(6 78 59 / 0.3); + color: #86efac; } .edr-badge-warning { - @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400; + background-color: #fef3c7; + color: #854d0e; + } + + .dark .edr-badge-warning { + background-color: rgb(120 53 15 / 0.3); + color: #fbbf24; } .edr-badge-danger { - @apply bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400; + background-color: #dcfce7; + color: rgb(20, 113, 76); + } + + .dark .edr-badge-danger { + background-color: rgb(6 78 59 / 0.3); + color: #86efac; } .edr-badge-info { - @apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400; + background-color: #dbeafe; + color: #1e40af; + } + + .dark .edr-badge-info { + background-color: rgb(30 58 138 / 0.3); + color: #60a5fa; } } diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 07a6d8bac..744cdccf9 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -1,115 +1,38 @@ /** @type {import('tailwindcss').Config} */ module.exports = { + darkMode: ['class'], content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], - darkMode: 'class', theme: { extend: { - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"', - ], - }, colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, + card: 'hsl(var(--card))', + 'card-foreground': 'hsl(var(--card-foreground))', + popover: 'hsl(var(--popover))', + 'popover-foreground': 'hsl(var(--popover-foreground))', + primary: 'hsl(var(--primary))', + 'primary-foreground': 'hsl(var(--primary-foreground))', + secondary: 'hsl(var(--secondary))', + 'secondary-foreground': 'hsl(var(--secondary-foreground))', + muted: 'hsl(var(--muted))', + 'muted-foreground': 'hsl(var(--muted-foreground))', + accent: 'hsl(var(--accent))', + 'accent-foreground': 'hsl(var(--accent-foreground))', + destructive: 'hsl(var(--destructive))', + 'destructive-foreground': 'hsl(var(--destructive-foreground))', border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', - primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - edr: { - blue: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - orange: { - 50: '#fff7ed', - 100: '#ffedd5', - 200: '#fed7aa', - 300: '#fdba74', - 400: '#fb923c', - 500: '#f97316', - 600: '#ea580c', - 700: '#c2410c', - 800: '#9a3412', - 900: '#7c2d12', - }, - red: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', - }, - }, - success: '#10b981', - warning: '#f59e0b', - danger: '#ef4444', - info: '#3b82f6', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', }, }, }, diff --git a/apps/edr-passenger-web/portal/next.config.js b/apps/edr-passenger-web/portal/next.config.js index 198341969..c0d91a2a0 100644 --- a/apps/edr-passenger-web/portal/next.config.js +++ b/apps/edr-passenger-web/portal/next.config.js @@ -1,10 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: 'export', reactStrictMode: true, + output: 'export', transpilePackages: ['@edr/types', '@edr/ui-common'], images: { - unoptimized: true, // Required for static export + unoptimized: true, }, }; diff --git a/apps/edr-passenger-web/portal/public/README.md b/apps/edr-passenger-web/portal/public/README.md new file mode 100644 index 000000000..7ea9c3ab4 --- /dev/null +++ b/apps/edr-passenger-web/portal/public/README.md @@ -0,0 +1,12 @@ +# Banner Image + +Place your banner image as `banner.jpg` in this directory. + +## Recommended Specifications: +- **Filename**: `banner.jpg` (or `banner.png`) +- **Dimensions**: 1920x1080px or higher +- **Aspect Ratio**: 16:9 or similar +- **Content**: Railway/train themed image, Ethio-Djibouti Railway scenery +- **Format**: JPG or PNG + +The image will be used as a background on the login page with a green overlay. diff --git a/apps/edr-passenger-web/portal/public/banner.jpg b/apps/edr-passenger-web/portal/public/banner.jpg new file mode 100644 index 000000000..09c6add92 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/banner.jpg differ diff --git a/apps/edr-passenger-web/portal/src/app/about/page.tsx b/apps/edr-passenger-web/portal/src/app/about/page.tsx new file mode 100644 index 000000000..00430aadc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/about/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import Link from 'next/link'; +import { Target, Globe, Leaf, Users } from 'lucide-react'; + +const styles = ` + .about-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .about-hero { + color: #f3f4f6; + } + + .about-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .about-hero h1 { + color: #f3f4f6; + } + + .about-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .about-hero p { + color: #9ca3af; + } + + .values-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + padding: 60px 20px; + background-color: white; + } + + .dark .values-grid { + background-color: #111827; + } + + .value-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 18px; + padding: 24px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .dark .value-card { + background: #1f2937; + border-color: #374151; + } + + .value-card:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .value-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + } + + .value-card h3 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 12px; + color: #111827; + } + + .dark .value-card h3 { + color: #f3f4f6; + } + + .value-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .value-card p { + color: #9ca3af; + } + + .stats-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .stats-section { + background-color: #0f1117; + } + + .stats-container { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 32px; + text-align: center; + } + + .stat { + padding: 20px; + } + + .stat-number { + font-size: 2rem; + font-weight: 700; + color: rgb(20, 113, 76); + margin-bottom: 8px; + } + + .stat-label { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .stat-label { + color: #9ca3af; + } + + .timeline-section { + padding: 60px 20px; + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + } + + .dark .timeline-section { + background: linear-gradient(135deg, #111827 0%, #0f1117 100%); + } + + .timeline-title { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 48px; + color: #111827; + } + + .dark .timeline-title { + color: #f3f4f6; + } + + .timeline { + max-width: 48rem; + margin: 0 auto; + position: relative; + } + + .timeline::before { + content: ''; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, rgb(20, 113, 76), rgb(20, 113, 76) 50%, transparent); + } + + .timeline-item { + display: flex; + margin-bottom: 40px; + position: relative; + padding-left: 56px; + animation: slideInLeft 0.6s ease-out forwards; + opacity: 0; + } + + .timeline-item:nth-child(1) { animation-delay: 0.1s; } + .timeline-item:nth-child(2) { animation-delay: 0.2s; } + .timeline-item:nth-child(3) { animation-delay: 0.3s; } + .timeline-item:nth-child(4) { animation-delay: 0.4s; } + .timeline-item:nth-child(5) { animation-delay: 0.5s; } + + @keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .timeline-dot { + position: absolute; + left: -4px; + top: 8px; + width: 24px; + height: 24px; + background: white; + border-radius: 50%; + border: 3px solid rgb(20, 113, 76); + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 4px 12px rgba(20, 113, 76, 0.3); + transition: all 0.3s ease; + } + + .timeline-item:hover .timeline-dot { + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 8px 24px rgba(20, 113, 76, 0.5); + transform: scale(1.15); + } + + .dark .timeline-dot { + background: #1f2937; + } + + .timeline-content { + background: white; + border-radius: 12px; + padding: 20px 24px; + border: 2px solid transparent; + border-left: 4px solid rgb(20, 113, 76); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + flex: 1; + } + + .timeline-item:hover .timeline-content { + border-color: rgb(20, 113, 76); + box-shadow: 0 8px 24px rgba(20, 113, 76, 0.15); + transform: translateY(-4px); + } + + .dark .timeline-content { + background: #1f2937; + border-left-color: rgb(20, 113, 76); + } + + .timeline-year { + font-weight: 700; + color: rgb(20, 113, 76); + font-size: 1.125rem; + display: flex; + align-items: center; + gap: 8px; + } + + .timeline-year::before { + content: '📅'; + } + + .timeline-event { + color: #6b7280; + margin-top: 8px; + font-size: 0.95rem; + font-weight: 500; + } + + .dark .timeline-event { + color: #d1d5db; + } + + .cta-blue { + background-color: rgb(20, 113, 76); + color: white; + padding: 60px 20px; + text-align: center; + } + + .cta-blue h2 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 16px; + } + + .cta-blue p { + font-size: 1.125rem; + margin-bottom: 32px; + max-width: 42rem; + margin-left: auto; + margin-right: auto; + } + + .button-white { + display: inline-block; + padding: 16px 32px; + background-color: white; + color: rgb(20, 113, 76); + font-weight: 700; + border-radius: 12px; + text-decoration: none; + transition: all 0.2s; + } + + .button-white:hover { + transform: scale(1.05); + } + + @media (max-width: 768px) { + .values-grid { + grid-template-columns: 1fr; + } + + .about-hero h1 { + font-size: 1.875rem; + } + } +`; + +export default function About() { + const [lang, setLang] = useState('en'); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const values = [ + { icon: Target, title: t('about.mission'), desc: t('about.missionText') }, + { icon: Globe, title: t('about.network'), desc: t('about.networkText') }, + { icon: Users, title: t('about.comfort'), desc: t('about.comfortText') }, + { icon: Leaf, title: t('about.eco'), desc: t('about.ecoText') }, + ]; + + return ( + <> + +
+
+

{t('about.title')}

+

{t('about.subtitle')}

+
+ +
+ {values.map((value, idx) => { + const Icon = value.icon; + return ( +
+
+ +
+

{value.title}

+

{value.desc}

+
+ ); + })} +
+ +
+
+
+
21
+
Railway Stations
+
+
+
360+
+
Comfortable Seats
+
+
+
3
+
Seat Classes
+
+
+
24/7
+
Customer Support
+
+
+
+ +
+

Our Journey

+
+ {[ + { year: '2020', event: 'EDR Platform Launched' }, + { year: '2021', event: 'Reached 10,000+ Passengers' }, + { year: '2022', event: 'Introduced Multi-Currency Support' }, + { year: '2023', event: 'Launched Loyalty Program' }, + { year: '2024', event: 'Age-Based Pricing & Verifayda Integration' }, + ].map((item, idx) => ( +
+
+
+
{item.year}
+
{item.event}
+
+
+ ))} +
+
+ +
+

Join Our Community

+

Be part of the modern railway revolution in East Africa.

+ Book Your First Journey +
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index 92297e36e..c1787010c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -33,7 +33,7 @@ export default function AuthCheckPage() {
{/* Header */}
-

Continue Your Booking

+

Continue your booking

Sign in to access saved profiles or continue as a guest

@@ -50,7 +50,7 @@ export default function AuthCheckPage() {
-

Sign In

+

Sign in

Access your saved passenger profiles and booking history for faster checkout

@@ -59,26 +59,26 @@ export default function AuthCheckPage() {
- +
Saved passenger details
- +
View booking history
- +
Faster future bookings
-
@@ -92,7 +92,7 @@ export default function AuthCheckPage() {
-

Continue as Guest

+

Continue as guest

Book without an account. You can create one after completing your booking

@@ -119,8 +119,8 @@ export default function AuthCheckPage() {
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 6bc0aa117..1a36ac04b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -6,31 +6,42 @@ import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; import { useMutation, useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; +type BookingWithTicket = { + id: string; + pnr?: string | null; + status?: string; + totalMinor?: number; + ticket?: { + barcodePayload?: string; + qrPayload?: string; + }; +}; + export default function ConfirmationPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); const [copied, setCopied] = useState(false); + const confirmAttempted = useRef(false); const confirmMutation = useMutation({ mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), }); - const { data: _booking } = useQuery({ + const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], - queryFn: async () => { + queryFn: async (): Promise => { try { return await apiClient.get(`/bookings/${bookingId}`); } catch (error) { console.log('Booking API not available, using local data'); - // Return mock booking data return { - id: bookingId, - pnr, + id: bookingId || '', + pnr: pnr || undefined, status: 'CONFIRMED', totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), }; @@ -40,10 +51,15 @@ export default function ConfirmationPage() { }); useEffect(() => { - if (bookingId && !confirmMutation.isSuccess && !confirmMutation.isPending) { + if (bookingId && !confirmAttempted.current) { + confirmAttempted.current = true; confirmMutation.mutate(); + + apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { + console.error('Failed to generate ticket:', err); + }); } - }, [bookingId]); + }, [bookingId, confirmMutation]); const copyPNR = () => { if (pnr) { @@ -54,7 +70,6 @@ export default function ConfirmationPage() { }; const handleDownloadTickets = () => { - // Mock download - in production this would call the API alert('Ticket download will be available soon. Your tickets are displayed below.'); }; @@ -71,15 +86,18 @@ export default function ConfirmationPage() { router.push('/booking/search'); }; - if (!bookingId || !pnr) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!bookingId || !pnr) { + router.push('/booking/search'); + } + }, [bookingId, pnr, router]); + + if (!bookingId || !pnr) return null; return (
-
+
{/* Success Header */}
@@ -87,14 +105,14 @@ export default function ConfirmationPage() {
-

Booking Confirmed!

+

Booking confirmed!

Your train tickets are ready

{/* PNR Card */}
-

Booking Reference (PNR)

+

Booking reference (PNR)

{pnr}
-

Trip Details

+

Trip details

-

Train Number

+

Train number

{selectedSchedule?.trainNumber}

@@ -161,11 +179,12 @@ export default function ConfirmationPage() { {/* Tickets */}
-

Your Tickets

+

Your tickets

{passengers.map((passenger, index) => { - const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; - const qrData = JSON.stringify({ + const backendTicket = _booking?.ticket || null; + const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; + const qrData = backendTicket?.qrPayload || JSON.stringify({ pnr, ticketNumber, passengerName: passenger.name, @@ -201,7 +220,7 @@ export default function ConfirmationPage() {

Seat

-

{passenger.seatId ? 'Assigned' : 'Will be assigned'}

+

{passenger.seatNumber || 'Will be assigned'}

@@ -263,7 +282,7 @@ export default function ConfirmationPage() { onClick={handleNewBooking} className="btn-primary w-full py-4 text-lg font-semibold" > - Book Another Trip + Book another trip {/* Info Notices */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 2b2aa3e40..79649c4b0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -1,7 +1,5 @@ 'use client'; -export const dynamic = 'force-dynamic'; - import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -29,10 +27,9 @@ const passengerSchema = z.object({ faydaSub: z.string().optional(), formExpanded: z.boolean().optional(), }).refine((data) => { - // For non-Ethiopian passengers, passport number and country are required if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { - return data.passportNumber && data.passportNumber.length > 0 && - data.passportCountry && data.passportCountry.length > 0; + return data.passportNumber && data.passportNumber.length > 0 && + data.passportCountry && data.passportCountry.length > 0; } return true; }, { @@ -49,12 +46,13 @@ type FormData = z.infer; export default function PassengersPage() { const router = useRouter(); - const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); + const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); const [faydaEnabled, setFaydaEnabled] = useState(true); const [verificationStatus, setVerificationStatus] = useState>({}); - const [updatingUser, setUpdatingUser] = useState(false); const [saving, setSaving] = useState(false); + const [formInitialized, setFormInitialized] = useState(false); + const [nationalityMismatch, setNationalityMismatch] = useState(false); const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); @@ -94,26 +92,82 @@ export default function PassengersPage() { } }; checkFaydaStatus(); + }, []); - if (isAuthenticated && user && searchCriteria?.nationality === 'ETHIOPIAN') { - if (user.faydaVerified && user.fullName && user.dateOfBirth) { - setValue('passengers.0.name', user.fullName); - setValue('passengers.0.dateOfBirth', user.dateOfBirth); - setValue('passengers.0.gender', user.gender as any); - setValue('passengers.0.nationality', user.nationality || 'ETHIOPIAN'); - setValue('passengers.0.phone', user.phone || ''); - setValue('passengers.0.email', user.email || ''); - setValue('passengers.0.faydaVerified', true); - setValue('passengers.0.faydaSub', user.faydaSub || ''); - setValue('passengers.0.formExpanded', true); - setVerificationStatus({ 0: 'success' }); - } + useEffect(() => { + if (isAuthenticated && user?.faydaVerified) { + setVerificationStatus({ 0: 'success' }); } + }, [isAuthenticated, user?.faydaVerified]); + + useEffect(() => { + const populateForm = async () => { + if (!isAuthenticated || !user?.id || !searchCriteria) { + console.log('Missing required data for population'); + setFormInitialized(true); + return; + } + + try { + // Fetch passenger profile from backend + const passengerData: any = await apiClient.get(`/passengers/me`); + console.log('Fetched passenger data:', passengerData); + + if (!passengerData) { + setFormInitialized(true); + return; + } + + const userNationality = (passengerData?.nationality || user.nationality || '').toUpperCase().trim(); + const searchNationality = (searchCriteria?.nationality || '').toUpperCase().trim(); + console.log('Nationalities:', { userNationality, searchNationality }); + + // Check for nationality mismatch + if (userNationality !== searchNationality) { + console.log('Nationality mismatch detected'); + setNationalityMismatch(true); + setFormInitialized(true); + return; + } + + // Only populate if nationalities match + console.log('Setting passenger 0 values'); + setValue('passengers.0.name', passengerData?.fullName || user.fullName || ''); + setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); + if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); + setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); + if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); + if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); + if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); + if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry); + if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); + if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate); + if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority); + setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false); + setValue('passengers.0.formExpanded', true); + + setFormInitialized(true); + } catch (error) { + console.error('Failed to fetch passenger data:', error); + setFormInitialized(true); + } + }; + + populateForm(); }, [isAuthenticated, user, searchCriteria, setValue]); + useEffect(() => { + if (nationalityMismatch && formInitialized) { + setTimeout(() => { + const element = document.getElementById('nationality-mismatch'); + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + }, [nationalityMismatch, formInitialized]); + const openFaydaVerification = async (index: number) => { if (typeof window === 'undefined') return; - + try { const response: any = await apiClient.post('/fayda/verification/start', { purpose: 'PURCHASE', @@ -126,7 +180,7 @@ export default function PassengersPage() { const height = 700; const left = (window.screen.width - width) / 2; const top = (window.screen.height - height) / 2; - + const popup = window.open( authorizationUrl, 'FaydaVerification', @@ -170,6 +224,19 @@ export default function PassengersPage() { const onSubmit = async (data: FormData) => { setSaving(true); try { + let passengerId = ''; + + // For authenticated users, fetch the passenger profile to get the passengerId + if (isAuthenticated && user?.id) { + try { + const passengerProfile: any = await apiClient.get('/passengers/me'); + passengerId = passengerProfile?.id || ''; + console.log('Fetched passengerId:', passengerId); + } catch (error) { + console.error('Failed to fetch passenger profile:', error); + } + } + const passengerDetails = data.passengers.map((p, i) => ({ name: p.name, dateOfBirth: p.dateOfBirth, @@ -181,20 +248,29 @@ export default function PassengersPage() { phone: p.phone, email: p.email, isPrimaryPassenger: i === 0, + passengerId: i === 0 && passengerId ? passengerId : undefined, })); - const deviceId = typeof window !== 'undefined' + const deviceId = typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || crypto.randomUUID()) : crypto.randomUUID(); - + await apiClient.post('/passengers/save-details', { passengers: passengerDetails, userId: user?.id, deviceId, }); - + setPassengers(passengerDetails); setCreateAccount(data.createAccount); + + // Save passengerId to booking store for later use + if (isAuthenticated && passengerId) { + const { setPassengerId } = useBookingStore.getState(); + setPassengerId(passengerId); + console.log('Saved passengerId to booking store:', passengerId); + } + router.push('/booking/seats'); } catch (error) { console.error('Failed to save passenger details:', error); @@ -204,16 +280,71 @@ export default function PassengersPage() { } }; - if (!searchCriteria) { - router.push('/booking/search'); - return null; + useEffect(() => { + if (!searchCriteria) { + router.push('/booking/search'); + } + }, [searchCriteria, router]); + + if (!searchCriteria) return null; + + if (nationalityMismatch && formInitialized) { + const searchLabel: Record = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' }; + return ( +
+
+
+
+
+
⚠️
+
+

Nationality Mismatch

+

+ You searched for an {searchLabel[searchCriteria.nationality] ?? searchCriteria.nationality} passenger, + but your account is registered as {user?.nationality}. +

+

+ You cannot proceed with this booking. Please restart and select the correct nationality on the search page. +

+ +
+
+
+
+
+
+ ); + } + + if (!formInitialized) { + return ( +
+
+
+ +

Loading passenger details...

+
+
+
+ ); } return (
-
-

Passenger Details

+
+

Passenger details

{fields.map((field, index) => { @@ -232,7 +363,7 @@ export default function PassengersPage() { Passenger {index + 1} {index === 0 && '(Primary)'} {index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'} - ({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'International'}) + ({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'}) @@ -249,27 +380,17 @@ export default function PassengersPage() { type="button" onClick={() => openFaydaVerification(index)} className="btn-primary flex items-center justify-center gap-2 mx-auto" - disabled={updatingUser} > - {updatingUser ? ( - - ) : ( - - )} - {updatingUser ? 'Updating Profile...' : 'Verify with Fayda'} + + {isLoggedInNotVerified ? 'Verify with Fayda' : 'Verify with Fayda'} + + -

- Click to verify your Ethiopian national ID -

- {!isLoggedInNotVerified && ( - - )}
) : showManualEntryLink ? (
@@ -281,238 +402,261 @@ export default function PassengersPage() { onClick={() => toggleForm(index)} className="btn-primary" > - Enter Details Manually + Enter details manually
) : ( -
- {isEthiopian ? ( - <> - {status === 'success' && ( -
-

- Verified with Fayda -

-
- )} +
+ {isEthiopian ? ( + <> + {status === 'success' && ( +
+

+ Verified with Fayda +

+
+ )} -
-
- - - {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

- )} -
- -
- - - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )} -
- -
- - -
- -
- - -
- -
- - -
- -
- - - {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

- )} -
-
- - ) : ( - <> -
-
- - - {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

- )} -
- -
- - - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )} -
- -
- - -
- -
- - -
- -
- - -
- -
- - - {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

- )} -
-
- -
- + setValue(`passengers.${index}.name`, e.target.value)} /> - {errors.passengers?.[index]?.passportNumber && ( -

{errors.passengers[index]?.passportNumber?.message}

+ {errors.passengers?.[index]?.name && ( +

{errors.passengers[index]?.name?.message}

)}
- + setValue(`passengers.${index}.dateOfBirth`, e.target.value)} /> - {errors.passengers?.[index]?.passportCountry && ( -

{errors.passengers[index]?.passportCountry?.message}

+ {errors.passengers?.[index]?.dateOfBirth && ( +

{errors.passengers[index]?.dateOfBirth?.message}

)}
- - Gender + +
+ +
+ +
- + setValue(`passengers.${index}.phone`, e.target.value)} />
- + setValue(`passengers.${index}.email`, e.target.value)} /> + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )}
-
- - )} -
+ + ) : ( + <> +
+
+ + setValue(`passengers.${index}.name`, e.target.value)} + /> + {errors.passengers?.[index]?.name && ( +

{errors.passengers[index]?.name?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + /> + {errors.passengers?.[index]?.dateOfBirth && ( +

{errors.passengers[index]?.dateOfBirth?.message}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + setValue(`passengers.${index}.phone`, e.target.value)} + /> +
+ +
+ + setValue(`passengers.${index}.email`, e.target.value)} + /> + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )} +
+
+ +
+
+
+ + setValue(`passengers.${index}.passportNumber`, e.target.value)} + /> + {errors.passengers?.[index]?.passportNumber && ( +

{errors.passengers[index]?.passportNumber?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.passportCountry`, e.target.value)} + /> + {errors.passengers?.[index]?.passportCountry && ( +

{errors.passengers[index]?.passportCountry?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.passportIssueDate`, e.target.value)} + /> +
+ +
+ + setValue(`passengers.${index}.passportExpiryDate`, e.target.value)} + /> +
+
+
+ + )} +
)}
); })} - -
- -
+ + {!isAuthenticated && ( +
+ +
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index e5bb44161..34f1fada1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -160,10 +160,10 @@ export default function PaymentPage() { return (
-
-

Complete Payment

+
+

Complete payment

- Booking Reference: {pnr} + Booking reference: {pnr}

{/* Payment Processing Overlay */} @@ -173,14 +173,14 @@ export default function PaymentPage() { {paymentMutation.isSuccess ? ( <> -

Payment Successful!

+

Payment successful!

Generating your tickets...

) : ( <> -

Processing Payment

+

Processing payment

Please wait while we process your payment...

)} @@ -190,7 +190,7 @@ export default function PaymentPage() { {/* Order Summary */}
-

Order Summary

+

Order summary

Route @@ -212,8 +212,8 @@ export default function PaymentPage() {
- Total Amount - + Total amount + ETB {(totalAmount / 100).toFixed(2)}
@@ -223,7 +223,7 @@ export default function PaymentPage() { {/* Payment Methods */}
-

Select Payment Method

+

Select payment method

{paymentMethods.map((method) => { const Icon = method.icon; @@ -285,7 +285,7 @@ export default function PaymentPage() { disabled={isProcessing} className="btn-secondary w-full py-2" > - Back to Review + Back to review
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 389eb2074..c8161bb34 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -138,12 +138,12 @@ export default function ResultsPage() {
-

No Trains Found

+

No trains found

- We couldn't find any trains matching your search criteria. Try adjusting your dates or route. + We couldn't find any trains matching your search criteria.
Try adjusting your dates or route.

@@ -153,18 +153,18 @@ export default function ResultsPage() { } return ( -
+
-
+
-

Available Trains

+

Available trains

@@ -202,7 +202,7 @@ export default function ResultsPage() {
-
+
@@ -267,7 +267,7 @@ export default function ResultsPage() { onClick={() => toggleExpanded(scheduleId)} className="btn-secondary w-full flex items-center justify-center gap-2" > - View Classes + Select class {isExpanded ? ( ) : ( @@ -295,9 +295,9 @@ export default function ResultsPage() { disabled={!isAvailable} className={`relative p-4 rounded-lg border-2 text-left transition-all ${ isSelected - ? 'border-primary bg-primary-50 dark:bg-primary-900/20 shadow-md' + ? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md' : isAvailable - ? 'border-gray-200 dark:border-gray-700 hover:border-primary-300 hover:shadow-sm' + ? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm' : 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed' }`} > diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 036cb5e36..6f9c2a265 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -2,14 +2,57 @@ import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; +import { useAuthStore } from '@/lib/auth-store'; import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; +// Helper function to decode JWT token and extract passengerId +function getPassengerIdFromToken(token: string): string | null { + try { + if (!token) { + console.warn('No token provided'); + return null; + } + + const parts = token.split('.'); + if (parts.length !== 3) { + console.warn('Invalid token format - expected 3 parts, got', parts.length); + return null; + } + + // Decode JWT payload with proper base64 padding + const payload = parts[1]; + const padded = payload + '='.repeat((4 - payload.length % 4) % 4); + + let decoded; + try { + decoded = JSON.parse(atob(padded)); + } catch (e) { + console.error('Failed to parse base64:', e); + return null; + } + + console.log('Decoded JWT payload keys:', Object.keys(decoded)); + console.log('passengerId from JWT:', decoded.passengerId); + + if (!decoded.passengerId) { + console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded)); + return null; + } + + return decoded.passengerId; + } catch (error) { + console.error('Error in getPassengerIdFromToken:', error); + return null; + } +} + export default function ReviewPage() { const router = useRouter(); - const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount } = useBookingStore(); + const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore(); + const { user, isAuthenticated } = useAuthStore(); const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); @@ -62,7 +105,10 @@ export default function ReviewPage() { }, [selectedSchedule?.id, passengers]); const createBookingMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/bookings/guest', data), + mutationFn: (data: any) => { + const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; + return apiClient.post(endpoint, data); + }, onSuccess: (data: any) => { console.log('Booking created successfully:', data); const bookingIdValue = data.bookingId || data.id; @@ -70,30 +116,25 @@ export default function ReviewPage() { console.log('Setting booking ID:', bookingIdValue); console.log('Setting PNR:', pnrValue); + console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest'); setBookingId(bookingIdValue); setPNR(pnrValue); - // Check if payment is required - const totalAmount = data.totalMinor || data.totalAmount || 0; + const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0); console.log('Total amount:', totalAmount); - console.log('Booking store after update:', useBookingStore.getState()); - // Use setTimeout to ensure state updates complete before navigation setTimeout(() => { - // Verify state was set const currentState = useBookingStore.getState(); console.log('Current booking store state:', currentState); console.log('bookingId:', currentState.bookingId); console.log('pnr:', currentState.pnr); if (totalAmount > 0) { - // Redirect to payment page console.log('Redirecting to payment page'); router.push('/booking/payment'); } else { - // No payment required, go directly to confirmation console.log('Redirecting to confirmation page'); router.push('/booking/confirmation'); } @@ -116,7 +157,6 @@ export default function ReviewPage() { console.log('Selected schedule:', selectedSchedule); console.log('Passengers:', passengers); - // Validate that we have a hold if (!seatHold?.holdId) { console.error('No seat hold found'); alert('Please select seats before continuing.'); @@ -124,7 +164,6 @@ export default function ReviewPage() { return; } - // Validate search criteria if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { console.error('Missing search criteria'); alert('Missing search criteria. Please start over.'); @@ -132,7 +171,6 @@ export default function ReviewPage() { return; } - // Get seat class ID let seatClassId = 'default-seat-class-id'; try { const seatClasses: any = await apiClient.get('/seat-classes'); @@ -144,37 +182,106 @@ export default function ReviewPage() { console.error('Failed to fetch seat classes:', err); } - const bookingData = { - scheduleId: selectedSchedule?.id || '', - holdId: seatHold.holdId, - originStationId: searchCriteria.originStationId, - destinationStationId: searchCriteria.destinationStationId, - seatClassId: seatClassId, - displayCurrency: 'ETB' as const, - passengers: passengers.map(p => { - const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; - const hasNationalId = isEthiopian && p.nationalId; - - return { - seatId: p.seatId || '', - passengerName: p.name, - dateOfBirth: p.dateOfBirth, - idDocumentType: hasNationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const, - idDocumentNumber: p.nationalId || undefined, - passportNumber: !hasNationalId ? p.passportNumber : undefined, - passportCountry: !hasNationalId ? p.passportCountry : undefined, - nationality: p.nationality, - phone: p.phone, - email: p.email, - }; - }), - createAccount: createAccount || false, - savePassengerDetails: true, - deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, - }; + let bookingData: any; + if (isAuthenticated) { + // For authenticated users: get passengerId from multiple sources + const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + + if (!token) { + console.error('No token in localStorage'); + throw new Error('Authentication token not found. Please log in again.'); + } - // Save deviceId for future use - if (typeof window !== 'undefined' && bookingData.deviceId && !localStorage.getItem('deviceId')) { + console.log('Token found, length:', token.length); + + let passengerId = getPassengerIdFromToken(token); + console.log('Extracted passenger ID from JWT token:', passengerId); + + // Fallback 1: Use passengerId from booking store + if (!passengerId && storedPassengerId) { + passengerId = storedPassengerId; + console.log('Fallback 1: Using passengerId from booking store:', passengerId); + } + + // Fallback 2: Use passengerId from localStorage + if (!passengerId && typeof window !== 'undefined') { + const localStoragePassengerId = localStorage.getItem('booking_passengerId'); + if (localStoragePassengerId) { + passengerId = localStoragePassengerId; + console.log('Fallback 2: Using passengerId from localStorage:', passengerId); + } + } + + // Fallback 3: Use passengerId from user object + if (!passengerId && user) { + passengerId = (user as any).passengerId; + console.log('Fallback 3: Using passengerId from user object:', passengerId); + } + + if (!passengerId) { + console.error('Failed to extract passengerId'); + console.error('User object:', user); + console.error('User object keys:', user ? Object.keys(user) : 'null'); + console.error('Stored passengerId from booking store:', storedPassengerId); + if (typeof window !== 'undefined') { + console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId')); + } + throw new Error('Passenger ID not found in authentication token. Please log in again.'); + } + + bookingData = { + scheduleId: selectedSchedule?.id || '', + holdId: seatHold.holdId, + originStationId: searchCriteria.originStationId, + destinationStationId: searchCriteria.destinationStationId, + seatClassId: seatClassId, + displayCurrency: 'ETB', + passengerId: passengerId, + passengers: passengers.map((p) => { + const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + return { + seatId: p.seatId || '', + passengerName: p.name, + dateOfBirth: p.dateOfBirth, + idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', + idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + passportNumber: !isEthiopian ? (p.passportNumber || '') : '', + passportCountry: !isEthiopian ? (p.passportCountry || '') : '', + nationality: p.nationality, + }; + }), + }; + } else { + // For guests: send full passenger details array + bookingData = { + scheduleId: selectedSchedule?.id || '', + holdId: seatHold.holdId, + originStationId: searchCriteria.originStationId, + destinationStationId: searchCriteria.destinationStationId, + seatClassId: seatClassId, + displayCurrency: 'ETB', + passengers: passengers.map(p => { + const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + return { + seatId: p.seatId || '', + passengerName: p.name, + dateOfBirth: p.dateOfBirth, + idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', + idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + passportNumber: !isEthiopian ? (p.passportNumber || '') : '', + passportCountry: !isEthiopian ? (p.passportCountry || '') : '', + nationality: p.nationality, + phone: p.phone || '', + email: p.email || '', + }; + }), + createAccount: createAccount || false, + savePassengerDetails: true, + deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, + }; + } + + if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { localStorage.setItem('deviceId', bookingData.deviceId); } @@ -182,11 +289,10 @@ export default function ReviewPage() { await createBookingMutation.mutateAsync(bookingData); } catch (error) { console.error('Error in handleConfirm:', error); - alert('An unexpected error occurred. Please try again.'); + alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); } }; - // Only redirect to search if we're not in the middle of creating a booking useEffect(() => { if (!selectedSchedule || !passengers.length) { if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { @@ -200,14 +306,11 @@ export default function ReviewPage() { return null; } - // Debug: Log selected schedule data console.log('Selected schedule:', selectedSchedule); console.log('Base fare adult:', selectedSchedule.baseFareAdult); console.log('Passengers:', passengers); - // Calculate fare - use the fare from selected schedule or from fare breakdown const baseFare = passengers.reduce((sum, p, i) => { - // Get the fare per passenger from the schedule const farePerPassenger = selectedSchedule.baseFareAdult || (selectedSchedule as any).fareAdult || (selectedSchedule as any).price || @@ -215,8 +318,6 @@ export default function ReviewPage() { console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); - // For now, charge all passengers the same fare - // TODO: Implement proper age-based pricing when we have dateOfBirth return sum + farePerPassenger; }, 0); @@ -227,8 +328,8 @@ export default function ReviewPage() { return (
-
-

Review Your Booking

+
+

Review your booking

{seatHold && (
@@ -240,7 +341,7 @@ export default function ReviewPage() {
-

Trip Details

+

Trip details

Train @@ -290,15 +391,15 @@ export default function ReviewPage() {
-

Fare Breakdown

+

Fare breakdown

- Base Fare + Base fare ETB {(baseFare / 100).toFixed(2)}
Total - ETB {(total / 100).toFixed(2)} + ETB {(total / 100).toFixed(2)}
@@ -312,7 +413,7 @@ export default function ReviewPage() { disabled={createBookingMutation.isPending} className="btn-primary flex-1" > - {createBookingMutation.isPending ? 'Creating Booking...' : 'Confirm & Pay'} + {createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 904212bfa..fa48c7610 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -5,11 +5,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useRouter, useSearchParams } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; -import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react'; -import { useEffect } from 'react'; +import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ @@ -30,6 +31,8 @@ export default function SearchPage() { const router = useRouter(); const searchParams = useSearchParams(); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); + const { user, isAuthenticated } = useAuthStore(); + const [isPassengerOpen, setIsPassengerOpen] = useState(false); const { data: stations, isLoading, error } = useQuery({ queryKey: ['stations'], @@ -49,7 +52,19 @@ export default function SearchPage() { }, }); - // Restore previous search values from URL params + useEffect(() => { + if (isAuthenticated && user?.nationality) { + const normalized = user.nationality.toUpperCase().trim(); + if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') { + setValue('nationality', 'DJIBOUTIAN'); + } else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') { + setValue('nationality', 'ETHIOPIAN'); + } else { + setValue('nationality', 'OTHER'); + } + } + }, [isAuthenticated, user?.nationality, setValue]); + useEffect(() => { const origin = searchParams.get('origin'); const destination = searchParams.get('destination'); @@ -67,19 +82,9 @@ export default function SearchPage() { }, [searchParams, setValue]); const originId = watch('originStationId'); - const destinationId = watch('destinationStationId'); const adultCount = watch('adultCount'); const childCount = watch('childCount'); - const swapStations = () => { - if (originId && destinationId) { - const tempOrigin = originId; - const tempDestination = destinationId; - setValue('originStationId', tempDestination); - setValue('destinationStationId', tempOrigin); - } - }; - const onSubmit = (data: SearchForm) => { setSearchCriteria(data); const params = new URLSearchParams({ @@ -117,11 +122,13 @@ export default function SearchPage() { { from: 'Diredawa', to: 'Nagad', duration: '4h' }, ]; + + return (
{/* Search Section */}
-
+
{/* Search Card */}
{/* Header inside card */} @@ -144,17 +151,19 @@ export default function SearchPage() { )}
-
-
- + {/* First Row: From, To, Date */} +
+ {/* From */} +
+
- + - + {stations?.map((s) => ( ))} @@ -193,11 +194,10 @@ export default function SearchPage() {

{errors.destinationStationId.message}

)}
-
-
-
- + {/* Date */} +
+ { @@ -207,98 +207,142 @@ export default function SearchPage() { setValue('departureDate', `${year}-${month}-${day}`); }} minDate={new Date()} - placeholder="Select departure date" + placeholder="Select date" /> {errors.departureDate && (

{errors.departureDate.message}

)}
+
+ {/* Second Row: Passengers, Nationality, Promo Code */} +
+ {/* Passengers Dropdown */} +
+ + + + {/* Passenger Dropdown Menu */} + {isPassengerOpen && ( + <> +
setIsPassengerOpen(false)} /> +
+ {/* Adults */} +
+
+
+
Adults
+
≥5 years
+
+
+ + {adultCount || 1} + +
+
+
+ + {/* Children */} +
+
+
+
Children
+
<5 years • First free
+
+
+ + {childCount || 0} + +
+
+
+
+ + )} +
+ + {/* Nationality */}
- -
-
-
-
Adults
-
≥5 years
-
-
- - {adultCount || 1} - -
-
-
-
-
Children
-
<5 years • First child free
-
-
- - {childCount || 0} - -
-
-
+ + +
+ + {/* Promo Code */} +
+ +
-
- - + {/* Third Row: Search Button */} +
+
- -
+ {/* Popular Routes */}

Popular Routes

@@ -312,7 +356,7 @@ export default function SearchPage() {
{route.from}
- +
{route.to}
@@ -323,29 +367,6 @@ export default function SearchPage() {
-
-
-
- -
-

Modern Fleet

-

Comfortable trains with modern amenities

-
-
-
- -
-

21 Stations

-

Connecting Ethiopia and Djibouti

-
-
-
- -
-

Easy Booking

-

Book tickets in just a few clicks

-
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 124fb39f1..80b28180c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -6,10 +6,36 @@ import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; import { useQuery, useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import CustomModal from '@/components/CustomModal'; +// Separate component for seat button to prevent re-render issues +const SeatButton = memo(({ seat, isSelected, onToggle }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; + + return ( + + ); +}); + +SeatButton.displayName = 'SeatButton'; + export default function SeatsPage() { const router = useRouter(); const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore(); @@ -29,20 +55,10 @@ export default function SeatsPage() { enabled: !!selectedSchedule?.id, }); - // Debug: Log the seat map data - useEffect(() => { - if (seatMapData) { - console.log('Seat map data:', seatMapData); - console.log('Is array?', Array.isArray(seatMapData)); - console.log('Has coaches?', (seatMapData as any)?.coaches); - } - }, [seatMapData]); - const holdMutation = useMutation({ mutationFn: async (seatIds: string[]) => { - // Create temporary passenger IDs for the hold const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({ - passengerId: `temp-${Date.now()}-${i}`, // Temporary ID for guest booking + passengerId: `temp-${Date.now()}-${i}`, seatId: seatIds[i], })); @@ -61,47 +77,21 @@ export default function SeatsPage() { }, }); - // Extract coaches and seats from seat map data - const coaches = (seatMapData as any)?.coaches || []; + const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]); - // Debug: Log coaches - useEffect(() => { - console.log('Coaches:', coaches); - console.log('Selected seat class:', selectedSchedule?.selectedSeatClass); - if (coaches.length > 0) { - console.log('First coach structure:', coaches[0]); - console.log('First coach seatClass:', coaches[0]?.seatClass); - console.log('First coach coachClass:', coaches[0]?.coachClass); - } + const filteredCoaches = useMemo(() => { + return selectedSchedule?.selectedSeatClass + ? coaches.filter((c: any) => { + const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || ''); + return seatClassName === selectedSchedule.selectedSeatClass || + seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() || + seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase(); + }) + : coaches; }, [coaches, selectedSchedule?.selectedSeatClass]); - // Filter coaches by selected seat class if available - const filteredCoaches = selectedSchedule?.selectedSeatClass - ? coaches.filter((c: any) => { - // seatClass can be either a string or an object with a name property - const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || ''); - console.log('Comparing:', seatClassName, 'with', selectedSchedule.selectedSeatClass); - return seatClassName === selectedSchedule.selectedSeatClass || - seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() || - seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase(); - }) - : coaches; - - // Debug filtered coaches - useEffect(() => { - console.log('Filtered coaches:', filteredCoaches); - console.log('Filtered coaches count:', filteredCoaches.length); - }, [filteredCoaches]); - - const selectedCoachData = filteredCoaches.find((c: any) => c.id === selectedCoach); - const seats = selectedCoachData?.seats || []; - - // Debug seats - useEffect(() => { - console.log('Selected coach data:', selectedCoachData); - console.log('Seats:', seats); - console.log('Seats count:', seats.length); - }, [selectedCoachData, seats]); + const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); + const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); useEffect(() => { if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) { @@ -109,21 +99,28 @@ export default function SeatsPage() { } }, [filteredCoaches, selectedCoach]); - const toggleSeat = (seatId: string) => { - if (selectedSeats.includes(seatId)) { - setSelectedSeats(selectedSeats.filter(id => id !== seatId)); - } else if (selectedSeats.length < passengers.length) { - setSelectedSeats([...selectedSeats, seatId]); - } - }; + const toggleSeat = useCallback((seatId: string) => { + setSelectedSeats(prev => { + if (prev.includes(seatId)) { + return prev.filter(id => id !== seatId); + } else if (prev.length < passengers.length) { + return [...prev, seatId]; + } + return prev; + }); + }, [passengers.length]); const handleContinue = async () => { if (selectedSeats.length > 0) { await holdMutation.mutateAsync(selectedSeats); - const updatedPassengers = passengers.map((p, i) => ({ - ...p, - seatId: selectedSeats[i], - })); + const updatedPassengers = passengers.map((p, i) => { + const seatData = seats?.find((s: any) => s.id === selectedSeats[i]); + return { + ...p, + seatId: selectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); setPassengers(updatedPassengers); } router.push('/booking/review'); @@ -146,10 +143,14 @@ export default function SeatsPage() { try { await holdMutation.mutateAsync(autoSelectedSeats); - const updatedPassengers = passengers.map((p, i) => ({ - ...p, - seatId: autoSelectedSeats[i], - })); + const updatedPassengers = passengers.map((p, i) => { + const seatData = availableSeats[i]; + return { + ...p, + seatId: autoSelectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); setPassengers(updatedPassengers); router.push('/booking/review'); } catch (error: any) { @@ -163,10 +164,13 @@ export default function SeatsPage() { } }; - if (!selectedSchedule || !passengers.length) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!selectedSchedule || !passengers.length) { + router.push('/booking/search'); + } + }, [selectedSchedule, passengers.length, router]); + + if (!selectedSchedule || !passengers.length) return null; return ( <> @@ -180,12 +184,12 @@ export default function SeatsPage() {
-

Select Seats

+

Select seats

-

Select Coach

+

Select coach

{selectedSchedule?.selectedSeatClassName && (
Showing coaches for: {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} @@ -215,7 +219,7 @@ export default function SeatsPage() {
-

Seat Map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}

+

Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}

{isLoading ? (

Loading seats...

@@ -228,35 +232,21 @@ export default function SeatsPage() { ) : seats.length === 0 ? (

No seats available in this coach

-

Please select a different coach

+

Please select a different coach

) : ( <> {/* Seat Grid */}
- {seats?.map((seat: any) => { - const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; - return ( - - ); - })} + {seats?.map((seat: any) => ( + + ))}
@@ -286,7 +276,7 @@ export default function SeatsPage() {
-

Selection Summary

+

Selection summary

Select {passengers.length} seat(s) for your passengers

@@ -314,14 +304,14 @@ export default function SeatsPage() { disabled={selectedSeats.length === 0} className="btn-primary w-full mb-2" > - Continue with Selected Seats + Continue with selected seats
diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx new file mode 100644 index 000000000..f2c0f4296 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; + +const styles = ` + .contact-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .contact-hero { + color: #f3f4f6; + } + + .contact-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .contact-hero h1 { + color: #f3f4f6; + } + + .contact-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .contact-hero p { + color: #9ca3af; + } + + .contact-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + padding: 60px 20px; + background-color: white; + } + + .dark .contact-grid { + background-color: #111827; + } + + .contact-card { + background: white; + border: 2px solid #f3f4f6; + border-radius: 18px; + padding: 24px; + cursor: pointer; + text-align: center; + transition: all 0.2s; + } + + .dark .contact-card { + background: #1f2937; + border-color: #374151; + } + + .contact-card:hover { + border-color: rgb(20, 113, 76); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .contact-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + } + + .contact-card h3 { + font-weight: 700; + margin-bottom: 8px; + color: #111827; + } + + .dark .contact-card h3 { + color: #f3f4f6; + } + + .contact-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .contact-card p { + color: #9ca3af; + } + + .contact-card a { + color: #6b7280; + text-decoration: none; + } + + .contact-card a:hover { + color: rgb(20, 113, 76); + } + + .form-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .form-section { + background-color: #0f1117; + } + + .form-container { + max-width: 42rem; + margin: 0 auto; + background: white; + border-radius: 18px; + padding: 32px; + border: 1px solid #e5e7eb; + } + + .dark .form-container { + background: #1f2937; + border-color: #374151; + } + + .form-container h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 24px; + color: #111827; + } + + .dark .form-container h2 { + color: #f3f4f6; + } + + .form-group { + margin-bottom: 20px; + } + + .form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin-bottom: 8px; + } + + .dark .form-group label { + color: #d1d5db; + } + + .form-group input, + .form-group textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #e5e7eb; + border-radius: 12px; + font-size: 1rem; + font-family: inherit; + transition: all 0.2s; + box-sizing: border-box; + background: white; + color: #111827; + } + + .dark .form-group input, + .dark .form-group textarea { + background: #111827; + color: #f3f4f6; + border-color: #374151; + } + + .form-group input:focus, + .form-group textarea:focus { + outline: none; + border-color: rgb(20, 113, 76); + box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1); + } + + .form-submit { + width: 100%; + padding: 14px 20px; + background-color: rgb(20, 113, 76); + color: white; + border: none; + border-radius: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + } + + .form-submit:hover { + background-color: rgb(16, 89, 60); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .form-submit:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.875rem; + } + + .alert-success { + background-color: #dbeafe; + color: #1e40af; + } + + .dark .alert-success { + background-color: rgba(20, 113, 76, 0.1); + color: #a7f3d0; + } + + .alert-error { + background-color: #fee2e2; + color: #991b1b; + } + + .dark .alert-error { + background-color: rgba(239, 68, 68, 0.1); + color: #fca5a5; + } +`; + +export default function Contact() { + const [lang, setLang] = useState('en'); + const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + await new Promise(resolve => setTimeout(resolve, 1500)); + setMessage({ type: 'success', text: t('contact.success') }); + setFormData({ name: '', email: '', subject: '', message: '' }); + } catch (error) { + setMessage({ type: 'error', text: t('contact.error') }); + } finally { + setLoading(false); + } + }; + + const contactInfo = [ + { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, + { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, + ]; + + return ( + <> + +
+
+

{t('contact.title')}

+

{t('contact.subtitle')}

+
+ +
+ {contactInfo.map((info, idx) => { + const Icon = info.icon; + return ( + +
+ +
+

{info.title}

+

{info.value}

+
+ ); + })} +
+ +
+
+

{t('contact.form')}

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, subject: e.target.value })} + /> +
+ +
+ +