Merge branch 'alpha' into passenger/feat/iam-integration

This commit is contained in:
Abubeker Yasin
2026-06-06 11:17:53 +03:00
72 changed files with 6395 additions and 1001 deletions

5
.gitignore vendored
View File

@@ -22,4 +22,7 @@ coverage/
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
.npmrc .npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat

View File

@@ -30,7 +30,6 @@
"@nestjs/core": "^11.1.19", "@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4", "@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
@@ -46,9 +45,8 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2",
"jose": "^5.10.0", "jose": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.21.0", "pg": "^8.21.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -64,9 +62,9 @@
"@nestjs/schematics": "^11.1.0", "@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19", "@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@@ -439,6 +439,7 @@ model Seat {
coach Coach @relation(fields: [coachId], references: [id]) coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[] bookingSeats BookingSeat[]
blocks SeatBlock[] blocks SeatBlock[]
ticketSeats TicketSeat[]
@@unique([coachId, row, col]) @@unique([coachId, row, col])
@@unique([coachId, seatNumber]) @@unique([coachId, seatNumber])
@@ -629,6 +630,20 @@ model Ticket {
validatorId String? validatorId String?
booking Booking @relation(fields: [bookingId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[] 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") @@schema("passenger")
} }

View File

@@ -113,8 +113,19 @@ async function main() {
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } }); const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
if (existingSchedules.length > 0) { if (existingSchedules.length > 0) {
const scheduleIds = existingSchedules.map(s => s.id); const scheduleIds = existingSchedules.map(s => s.id);
// Delete in correct order to avoid foreign key constraints const bookingIds = (
await prisma.bookingSeat.deleteMany({ where: { booking: { scheduleId: { in: scheduleIds } } } }); 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.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } }); await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });

View File

@@ -245,6 +245,7 @@ Payment providers send notifications to:
.addTag("Support", "FAQ management and live chat support") .addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation") .addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger") .addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
//.addServer('http://localhost:4000', 'Development') //.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production") // .addServer("https://api.edr-platform.com", "Production")
.build(); .build();

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service'; import { GuestBookingService } from './guest-booking.service';
@@ -14,6 +14,63 @@ export class BookingsController {
private guestService: GuestBookingService, 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() @Get()
@ApiOperation({ @ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)', summary: 'List all bookings with filters (Admin/Agent)',
@@ -108,6 +165,17 @@ export class BookingsController {
return this.service.getByRef(ref); 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') @Patch(':bookingRef/modify')
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@@ -121,6 +189,28 @@ export class BookingsController {
return this.service.modify(dto); 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') @Delete(':bookingRef')
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')

View File

@@ -38,6 +38,147 @@ export class BookingsService {
private currencyService: CurrencyService, 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 = {}) { async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters; const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
@@ -154,7 +295,6 @@ export class BookingsService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); 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 primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount; const adultFareMinor = baseFareMinor * adultCount;
@@ -302,6 +442,60 @@ export class BookingsService {
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; 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) @Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() { async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000); const cutoff = new Date(Date.now() - 20 * 60 * 1000);

View File

@@ -168,12 +168,19 @@ export class GuestBookingService {
throw new BadRequestException('Email already registered. Please login instead.'); 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 passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
fullName: firstPassenger.passengerName, fullName: firstPassenger.passengerName,
email: firstPassenger.email, email: firstPassenger.email,
phone: firstPassenger.phone || '', phone: accountPhone,
passwordHash, passwordHash,
nationality: firstPassenger.nationality, nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, 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({ const tempUser = await this.prisma.user.create({
data: { data: {
fullName: firstPassenger.passengerName, fullName: firstPassenger.passengerName,
email: guestEmail, 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), passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER', role: 'PASSENGER',
}, },
@@ -235,6 +250,7 @@ export class GuestBookingService {
displayCurrency, displayCurrency,
displayTotalMinor, displayTotalMinor,
bookingType: 'ONE_WAY', bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration // contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration // contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: { seats: {

View File

@@ -1,12 +1,17 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { FareEngineService } from './fare-engine.service'; import { FareEngineService } from './fare-engine.service';
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto'; import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
import { FaydaConfig } from '../../config/fayda.config';
@ApiTags('Fare Engine') @ApiTags('Fare Engine')
@Controller('fare-engine') @Controller('fare-engine')
export class FareEngineController { export class FareEngineController {
constructor(private service: FareEngineService) {} constructor(
private service: FareEngineService,
private configService: ConfigService,
) {}
@Post('calculate') @Post('calculate')
@ApiOperation({ @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<FaydaConfig>('fayda');
const verifaydaEnabled = this.configService.get<boolean>('VERIFAYDA_ENABLED', false);
return {
enabled: faydaConfig?.enabled || verifaydaEnabled,
mode: verifaydaEnabled ? 'production' : 'development',
apiUrl: this.configService.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'),
};
}
}

View File

@@ -1,12 +1,12 @@
import { Module } from '@nestjs/common'; 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 { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller'; import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module'; import { CurrencyModule } from '../currency/currency.module';
@Module({ @Module({
imports: [CurrencyModule], imports: [CurrencyModule],
controllers: [FareEngineController, CurrencyController], controllers: [FareEngineController, CurrencyController, ConfigController],
providers: [FareEngineService], providers: [FareEngineService],
exports: [FareEngineService], exports: [FareEngineService],
}) })

View File

@@ -22,6 +22,14 @@ export class FleetController {
@ApiResponse({ status: 201, description: 'Train created' }) @ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); } 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') @Get('coaches')
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' }) @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' }) @ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })

View File

@@ -112,6 +112,12 @@ export class FleetService {
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } 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) { async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ const coach = await this.prisma.coach.findUnique({
where: { id }, where: { id },

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { PassengersService } from './passengers.service'; import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PrismaService } from '../../common/prisma.service';
@ApiTags('Passengers') @ApiTags('Passengers')
@Controller('passengers') @Controller('passengers')
@@ -12,6 +13,7 @@ export class PassengersController {
constructor( constructor(
private service: PassengersService, private service: PassengersService,
private verifaydaService: VerifaydaService, private verifaydaService: VerifaydaService,
private prisma: PrismaService,
) {} ) {}
@Get() @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') @Get(':id/profile')
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@@ -313,4 +351,37 @@ Returns saved passenger details with generated IDs and confirmation.`,
getSavedRoutes(@Param('id') id: string) { getSavedRoutes(@Param('id') id: string) {
return this.service.getSavedRoutes(id); 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);
}
} }

View File

@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
import { PassengersController } from './passengers.controller'; import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service'; import { PassengersService } from './passengers.service';
import { VerifaydaModule } from '../verifayda/verifayda.module'; import { VerifaydaModule } from '../verifayda/verifayda.module';
import { PrismaModule } from '../../common/prisma.module';
@Module({ @Module({
imports: [VerifaydaModule, HttpModule], imports: [VerifaydaModule, HttpModule, PrismaModule],
controllers: [PassengersController], controllers: [PassengersController],
providers: [PassengersService] providers: [PassengersService]
}) })

View File

@@ -180,6 +180,28 @@ export class PassengersService {
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } 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) { async registerPassenger(dto: RegisterPassengerDto) {
const isEthiopian = !!dto.nationalId; const isEthiopian = !!dto.nationalId;
const isLoggedIn = !!dto.userId; const isLoggedIn = !!dto.userId;
@@ -270,4 +292,30 @@ export class PassengersService {
message: 'Passenger details saved for guest booking', 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,
};
}
} }

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { PaymentsService } from './payments.service'; 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 { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard'; import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator'; import { Roles } from '../../common/roles.decorator';
@@ -62,4 +63,113 @@ export class PaymentsController {
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } 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, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
} }

View File

@@ -182,8 +182,6 @@ export class PaymentsService {
return this.formatIntentResponse(intent); return this.formatIntentResponse(intent);
} }
private formatIntentResponse( private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>, intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): InitiateResponseDto { ): InitiateResponseDto {
@@ -349,9 +347,31 @@ export class PaymentsService {
}); });
}); });
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); try {
await this.ticketsService.generate(booking.id); await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); } 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 }); this.eventEmitter.emit('payment.succeeded', { booking });
return { alreadyFinalized: false }; 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.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 } }); 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 });
}
}
} }

View File

@@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider {
merch_code: this.merchantCode, merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId, merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const, trade_type: 'Checkout' as const,
title: `EDR Booking ${input.bookingRef}`, title: `EDR Booking`,
total_amount: totalAmount, total_amount: totalAmount,
trans_currency: input.currency, trans_currency: input.currency,
timeout_express: this.timeoutExpress, timeout_express: this.timeoutExpress,

View File

@@ -353,9 +353,13 @@ export class SeatsService {
// No-op for status — availability is segment-scoped via JourneySegment // 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 // seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
} }
async releaseSeats(seatIds: string[]) { async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed if (seatIds.length > 0) {
// For segment-based bookings, releasing is handled by JourneySegment deletion await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
} }
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> { async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
@@ -480,6 +484,16 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); 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;
}
}
}
} }
} }

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Tickets') @ApiTags('Tickets')
@Controller('tickets') @Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController { export class TicketsController {
constructor(private service: TicketsService) {} 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') @Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Get ticket with QR code and passenger details', summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including: description: `Returns ticket information including:
@@ -26,6 +62,8 @@ export class TicketsController {
} }
@Post(':bookingRef/validate') @Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Validate ticket at gate with audit logging', 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.' 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') @Get(':ticketId/validation-logs')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' }) @ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) { getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId); return this.service.getValidationLogs(ticketId);
} }
@Get('offline/export') @Get('offline/export')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' }) @ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) { exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId); return this.service.exportOfflineData(scheduleId);
} }
@Post('validate/offline') @Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' }) @ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) { validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations); 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);
}
} }

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller'; import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service'; 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 class TicketsModule {}
export { TicketsController } from './tickets.controller';

View File

@@ -13,19 +13,142 @@ interface OfflineValidation {
export class TicketsService { export class TicketsService {
constructor(private prisma: PrismaService) {} 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) { async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({ const booking = await this.prisma.booking.findUnique({
where: { id: bookingId }, 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 qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId }, const ticket = await this.prisma.ticket.upsert({
update: { qrPayload, barcodePayload }, where: { bookingId },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload } 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) { async getByRef(bookingRef: string) {
@@ -147,4 +270,22 @@ export class TicketsService {
return results; 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 };
}
} }

View File

@@ -2,12 +2,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton'; 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 { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types'; import { BookingFilters } from '@/types';
@@ -18,6 +20,10 @@ export default function BookingsPage() {
search: '', search: '',
status: '', status: '',
}); });
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -34,16 +40,46 @@ export default function BookingsPage() {
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] }); 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) => { 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' }); 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 = [ const columns = [
{ {
key: 'bookingRef', key: 'bookingRef',
@@ -94,13 +130,12 @@ export default function BookingsPage() {
]; ];
const actions = [ const actions = [
// TODO: Create booking detail page {
// { label: 'View Details',
// label: 'View Details', onClick: (booking: any) => setSelectedBooking(booking),
// onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`, variant: 'secondary' as const,
// variant: 'secondary' as const, icon: Eye,
// icon: Eye, },
// },
{ {
label: 'Cancel Booking', label: 'Cancel Booking',
onClick: handleCancel, onClick: handleCancel,
@@ -108,6 +143,12 @@ export default function BookingsPage() {
icon: XCircle, icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED', show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
}, },
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
},
]; ];
return ( return (
@@ -121,6 +162,11 @@ export default function BookingsPage() {
</div> </div>
<div className="card"> <div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200"> <div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'} Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
@@ -166,6 +212,163 @@ export default function BookingsPage() {
/> />
)} )}
</div> </div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>
{selectedBooking.status}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
</div>
</div>
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Adults</label>
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Children</label>
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
{selectedBooking.paymentIntent?.status || 'PENDING'}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Source</label>
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
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}
/>
</div> </div>
); );
} }

View File

@@ -6,12 +6,14 @@ import { fleetApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react'; import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
export default function CoachesPage() { export default function CoachesPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null); const [editingCoach, setEditingCoach] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -65,9 +67,14 @@ export default function CoachesPage() {
} }
}; };
const handleDelete = async (coach: any) => { const handleDelete = (coach: any) => {
if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) { setDeleteConfirm({ isOpen: true, coach });
await deleteMutation.mutateAsync(coach.id); };
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() {
/> />
</div> </div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */} {/* Add/Edit Modal */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}

View File

@@ -93,7 +93,7 @@ export default function LoginPage() {
disabled={loading} disabled={loading}
className="btn btn-primary w-full disabled:opacity-50" className="btn btn-primary w-full disabled:opacity-50"
> >
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign in'}
</button> </button>
</form> </form>

View File

@@ -1,14 +1,16 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { UserPlus, Download, Eye } from 'lucide-react'; import { Download, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import { passengersApi } from '@/lib/api'; import Modal from '@/components/ui/Modal';
import { formatDate } from '@/lib/utils'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types'; import { PassengerFilters } from '@/types';
export default function PassengersPage() { export default function PassengersPage() {
@@ -17,6 +19,28 @@ export default function PassengersPage() {
pageSize: 20, pageSize: 20,
search: '', search: '',
}); });
const [selectedPassenger, setSelectedPassenger] = useState<any>(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({ const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters], queryKey: ['passengers', filters],
@@ -65,14 +89,19 @@ export default function PassengersPage() {
}, },
]; ];
const actions: any[] = [ const actions = [
// TODO: Create passenger detail page {
// { label: 'View Details',
// label: 'View Details', onClick: (passenger: any) => setSelectedPassenger(passenger),
// onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`, variant: 'secondary' as const,
// variant: 'secondary' as const, icon: Eye,
// icon: Eye, },
// }, {
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
]; ];
return ( return (
@@ -130,6 +159,184 @@ export default function PassengersPage() {
/> />
)} )}
</div> </div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */}
<Modal
isOpen={!!selectedPassenger}
onClose={() => setSelectedPassenger(null)}
title="Passenger Details"
size="xl"
>
{selectedPassenger && (
<div className="space-y-6">
{/* Personal Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
<p className="text-lg font-semibold">
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Gender</label>
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Contact Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Identification */}
<div>
<h3 className="text-lg font-semibold mb-3">Identification</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">National ID</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.nationalId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
<div className="mt-1">
<Badge
variant="status"
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
>
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
</Badge>
</div>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Account Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedPassenger.id}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">User ID</label>
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
</div>
</div>
</div>
{/* Loyalty & Wallet (if available) */}
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
<>
<hr className="border-muted" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedPassenger.loyalty && (
<div>
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Tier</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
</div>
</div>
</div>
)}
{selectedPassenger.wallet && (
<div>
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Balance</label>
<p className="text-lg font-semibold">
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
<hr className="border-muted" />
{/* Timestamps */}
<div>
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedPassenger(null)}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
</div> </div>
); );
} }

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes'; import { routesApi } from '@/lib/api/routes';
import { stationsApi } from '@/lib/api'; import { stationsApi } from '@/lib/api';
@@ -24,6 +25,7 @@ export default function RoutesPage() {
const [originStationId, setOriginStationId] = useState(''); const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined); const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({ const { data: routes, isLoading: routesLoading } = useQuery({
@@ -148,9 +150,14 @@ export default function RoutesPage() {
return origin && dest ? `${origin.name} - ${dest.name}` : ''; return origin && dest ? `${origin.name} - ${dest.name}` : '';
}; };
const handleDelete = async (route: any) => { const handleDelete = (route: any) => {
if (confirm(`Are you sure you want to delete ${route.name}?`)) { setDeleteConfirm({ isOpen: true, route });
await deleteMutation.mutateAsync(route.id); };
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" emptyMessage="No routes found"
/> />
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */} {/* Add/Edit Modal */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}
@@ -261,6 +280,12 @@ export default function RoutesPage() {
size="lg" size="lg"
> >
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto"> <form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{editingRoute && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="label">Origin Station *</label> <label className="label">Origin Station *</label>

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi } from '@/lib/api'; import { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
@@ -14,6 +15,7 @@ export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null); const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -63,9 +65,14 @@ export default function SeatClassesPage() {
} }
}; };
const handleDelete = async (seatClass: any) => { const handleDelete = (seatClass: any) => {
if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) { setDeleteConfirm({ isOpen: true, seatClass });
await deleteMutation.mutateAsync(seatClass.id); };
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() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Seat Classes</h1> <h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p> <p className="text-muted-foreground">Manage seat class configurations</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -132,6 +139,18 @@ export default function SeatClassesPage() {
emptyMessage="No seat classes found" emptyMessage="No seat classes found"
/> />
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */} {/* Add/Edit Modal */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}

View File

@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { stationsApi } from '@/lib/api'; import { stationsApi } from '@/lib/api';
import { Station } from '@/types'; import { Station } from '@/types';
@@ -14,6 +15,7 @@ export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null); const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -67,9 +69,14 @@ export default function StationsPage() {
} }
}; };
const handleDelete = async (station: any) => { const handleDelete = (station: any) => {
if (confirm(`Are you sure you want to delete ${station.name}?`)) { setDeleteConfirm({ isOpen: true, station });
await deleteMutation.mutateAsync(station.id); };
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" emptyMessage="No stations found"
/> />
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */} {/* Add/Edit Modal */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}
@@ -234,6 +253,12 @@ export default function StationsPage() {
size="lg" size="lg"
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{editingStation && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="label">Station Code *</label> <label className="label">Station Code *</label>

View File

@@ -2,27 +2,35 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; 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'; import { formatDateTime } from '@/lib/utils';
export default function TicketsPage() { export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '' }); const [filters, setFilters] = useState({ search: '', status: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters], queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll(filters), queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
}); });
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate, mutationFn: ticketsApi.regenerate,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); 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), mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); 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) => { 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); 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 = [ const columns = [
{ {
key: 'ticketNumber', key: 'ticketNumber',
@@ -121,15 +159,8 @@ export default function TicketsPage() {
]; ];
const actions = [ 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, onClick: handleValidate,
variant: 'primary' as const, variant: 'primary' as const,
icon: CheckCircle, icon: CheckCircle,
@@ -141,6 +172,12 @@ export default function TicketsPage() {
variant: 'secondary' as const, variant: 'secondary' as const,
icon: RefreshCw, icon: RefreshCw,
}, },
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
},
]; ];
return ( return (
@@ -155,6 +192,16 @@ export default function TicketsPage() {
{/* Filters */} {/* Filters */}
<div className="card"> <div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="label">Search</label> <label className="label">Search</label>
@@ -191,6 +238,22 @@ export default function TicketsPage() {
loading={isLoading} loading={isLoading}
emptyMessage="No tickets found" emptyMessage="No tickets found"
/> />
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
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}
/>
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ import { Plus, Edit, Trash2, Train } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import { fleetApi } from '@/lib/api'; import { fleetApi } from '@/lib/api';
import { Train as TrainType } from '@/types'; import { Train as TrainType } from '@/types';
@@ -14,6 +15,7 @@ import { formatDate } from '@/lib/utils';
export default function TrainsPage() { export default function TrainsPage() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null); const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -28,6 +30,10 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false); setShowModal(false);
setEditingTrain(null); 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'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false); setShowModal(false);
setEditingTrain(null); 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 handleSubmit = async (formData: FormData) => {
const trainData = { const trainData = {
number: formData.get('number') as string, number: formData.get('number') as string,
@@ -113,6 +142,12 @@ export default function TrainsPage() {
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
]; ];
return ( return (
@@ -142,6 +177,18 @@ export default function TrainsPage() {
emptyMessage="No trains found" emptyMessage="No trains found"
/> />
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 */} {/* Add/Edit Modal */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}
@@ -160,6 +207,12 @@ export default function TrainsPage() {
}} }}
className="space-y-4" className="space-y-4"
> >
{editingTrain && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
<p className="mt-1">Editing this train may impact schedules and bookings that reference it. Proceed with caution.</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="label">Train Number *</label> <label className="label">Train Number *</label>

View File

@@ -62,7 +62,7 @@ const navigationSections = [
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 }, { name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair }, { name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Schedules', href: '/schedules', icon: Calendar }, { 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: [ items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign }, { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Payments', href: '/payments', icon: CreditCard }, { name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Wallet Management', href: '/wallet', icon: Wallet },
{ name: 'Promotions', href: '/promotions', icon: Gift }, { name: 'Promotions', href: '/promotions', icon: Gift },
] ]
}, },

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="space-y-4">
<div className="flex gap-3">
{isDanger && (
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<p className="text-foreground">{message}</p>
</div>
{warning && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900 dark:text-amber-200 text-sm">Warning</p>
<p className="text-amber-800 dark:text-amber-300 text-sm mt-1">{warning}</p>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}
</ActionButton>
<ActionButton
variant={isDanger ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
</ActionButton>
</div>
</div>
</Modal>
);
}

View File

@@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} /> <div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}> <div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background shadow-xl flex flex-col max-h-[90vh]`}>
<div className="mb-4 flex items-center justify-between"> <div className="sticky top-0 bg-background border-b border-muted px-6 py-4 flex items-center justify-between z-10">
<h2 className="text-xl font-semibold text-foreground">{title}</h2> <h2 className="text-xl font-semibold text-foreground">{title}</h2>
<button <button
onClick={onClose} onClick={onClose}
@@ -45,7 +45,9 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
<X className="h-5 w-5 text-muted-foreground" /> <X className="h-5 w-5 text-muted-foreground" />
</button> </button>
</div> </div>
<div>{children}</div> <div className="overflow-y-auto flex-1 px-6 py-4">
{children}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,9 @@
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PaginatedResponse } from '@edr/types'; import { PaginatedResponse } from '@edr/types';
// Export apiClient for direct use
export { apiClient };
// Bookings API // Bookings API
export const bookingsApi = { export const bookingsApi = {
getAll: async (params?: any) => { getAll: async (params?: any) => {
@@ -15,8 +18,32 @@ export const bookingsApi = {
return Array.isArray(response) ? { items: response } : response; return Array.isArray(response) ? { items: response } : response;
}, },
getById: (id: string) => apiClient.get<any>(`/bookings/${id}`), getById: (id: string) => apiClient.get<any>(`/bookings/${id}`),
getMy: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/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<string, string>;
cleanParams['deviceId'] = deviceId;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/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<any>(`/bookings/${id}/cancel`, data), cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data), modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
}; };
// Passengers API // Passengers API
@@ -140,7 +167,10 @@ export const paymentsApi = {
// Tickets API // Tickets API
export const ticketsApi = { export const ticketsApi = {
getAll: async (params?: any) => { getAll: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString(); const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`); const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`);
if (response?.data) { if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response; return Array.isArray(response.data) ? { items: response.data } : response;

View File

@@ -8,16 +8,25 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
}).format(amount / 100); }).format(amount / 100);
}; };
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
return format(new Date(date), formatStr); 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 => { export const formatDateTime = (date?: string | Date | null): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm'); 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 => { export const formatDateTimeLocal = (date?: string | Date | null): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm'); 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 => { export const getStatusColor = (status: string): string => {

View File

@@ -55,64 +55,136 @@
} }
body { body {
@apply bg-background text-foreground; background-color: hsl(var(--background));
color: hsl(var(--foreground));
} }
} }
@layer components { @layer components {
.card { .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 { .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 { .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 { .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 { .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 { .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 { .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 { .gradient-edr {
@apply bg-[rgb(20,113,76)]; background-color: rgb(20, 113, 76);
} }
.gradient-edr-bg { .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 { .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 { .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 { .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 { .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 { .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;
} }
} }

View File

@@ -1,115 +1,38 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ['class'],
content: [ content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}',
], ],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
],
},
colors: { colors: {
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
card: { card: 'hsl(var(--card))',
DEFAULT: 'hsl(var(--card))', 'card-foreground': 'hsl(var(--card-foreground))',
foreground: 'hsl(var(--card-foreground))', popover: 'hsl(var(--popover))',
}, 'popover-foreground': 'hsl(var(--popover-foreground))',
popover: { primary: 'hsl(var(--primary))',
DEFAULT: 'hsl(var(--popover))', 'primary-foreground': 'hsl(var(--primary-foreground))',
foreground: 'hsl(var(--popover-foreground))', secondary: 'hsl(var(--secondary))',
}, 'secondary-foreground': 'hsl(var(--secondary-foreground))',
primary: { muted: 'hsl(var(--muted))',
DEFAULT: 'hsl(var(--primary))', 'muted-foreground': 'hsl(var(--muted-foreground))',
foreground: 'hsl(var(--primary-foreground))', accent: 'hsl(var(--accent))',
}, 'accent-foreground': 'hsl(var(--accent-foreground))',
secondary: { destructive: 'hsl(var(--destructive))',
DEFAULT: 'hsl(var(--secondary))', 'destructive-foreground': 'hsl(var(--destructive-foreground))',
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))',
},
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
primary: { },
50: '#eff6ff', borderRadius: {
100: '#dbeafe', lg: 'var(--radius)',
200: '#bfdbfe', md: 'calc(var(--radius) - 2px)',
300: '#93c5fd', sm: 'calc(var(--radius) - 4px)',
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',
}, },
}, },
}, },

View File

@@ -1,10 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'export',
reactStrictMode: true, reactStrictMode: true,
output: 'export',
transpilePackages: ['@edr/types', '@edr/ui-common'], transpilePackages: ['@edr/types', '@edr/ui-common'],
images: { images: {
unoptimized: true, // Required for static export unoptimized: true,
}, },
}; };

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

View File

@@ -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<Language>('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 (
<>
<style>{styles}</style>
<main>
<section className="about-hero">
<h1>{t('about.title')}</h1>
<p>{t('about.subtitle')}</p>
</section>
<section className="values-grid">
{values.map((value, idx) => {
const Icon = value.icon;
return (
<div key={idx} className="value-card">
<div className="value-icon">
<Icon size={24} color="white" />
</div>
<h3>{value.title}</h3>
<p>{value.desc}</p>
</div>
);
})}
</section>
<section className="stats-section">
<div className="stats-container">
<div className="stat">
<div className="stat-number">21</div>
<div className="stat-label">Railway Stations</div>
</div>
<div className="stat">
<div className="stat-number">360+</div>
<div className="stat-label">Comfortable Seats</div>
</div>
<div className="stat">
<div className="stat-number">3</div>
<div className="stat-label">Seat Classes</div>
</div>
<div className="stat">
<div className="stat-number">24/7</div>
<div className="stat-label">Customer Support</div>
</div>
</div>
</section>
<section className="timeline-section">
<h2 className="timeline-title">Our Journey</h2>
<div className="timeline">
{[
{ 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) => (
<div key={idx} className="timeline-item">
<div className="timeline-dot" />
<div className="timeline-content">
<div className="timeline-year">{item.year}</div>
<div className="timeline-event">{item.event}</div>
</div>
</div>
))}
</div>
</section>
<section className="cta-blue">
<h2>Join Our Community</h2>
<p>Be part of the modern railway revolution in East Africa.</p>
<Link href="/booking/search" className="button-white">Book Your First Journey</Link>
</section>
</main>
</>
);
}

View File

@@ -33,7 +33,7 @@ export default function AuthCheckPage() {
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-12 animate-fade-in"> <div className="text-center mb-12 animate-fade-in">
<h1 className="section-title">Continue Your Booking</h1> <h1 className="section-title">Continue your booking</h1>
<p className="section-subtitle mt-2"> <p className="section-subtitle mt-2">
Sign in to access saved profiles or continue as a guest Sign in to access saved profiles or continue as a guest
</p> </p>
@@ -50,7 +50,7 @@ export default function AuthCheckPage() {
<div className="w-20 h-20 bg-gradient-to-br from-primary to-primary-700 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow"> <div className="w-20 h-20 bg-gradient-to-br from-primary to-primary-700 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
<LogIn className="w-10 h-10 text-white" /> <LogIn className="w-10 h-10 text-white" />
</div> </div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign In</h2> <h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign in</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance"> <p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
Access your saved passenger profiles and booking history for faster checkout Access your saved passenger profiles and booking history for faster checkout
</p> </p>
@@ -59,26 +59,26 @@ export default function AuthCheckPage() {
<div className="space-y-3 mb-6 text-left"> <div className="space-y-3 mb-6 text-left">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary text-xs"></span> <span className="text-primary dark:text-gray-300 text-xs"></span>
</div> </div>
<span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span> <span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary text-xs"></span> <span className="text-primary dark:text-gray-300 text-xs"></span>
</div> </div>
<span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span> <span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary text-xs"></span> <span className="text-primary dark:text-gray-300 text-xs"></span>
</div> </div>
<span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span> <span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span>
</div> </div>
</div> </div>
<button className="btn-primary w-full"> <button className="btn-primary w-full text-center">
Sign In to Continue Sign in to continue
</button> </button>
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@ export default function AuthCheckPage() {
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow"> <div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
<UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" /> <UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" />
</div> </div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as Guest</h2> <h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as guest</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance"> <p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
Book without an account. You can create one after completing your booking Book without an account. You can create one after completing your booking
</p> </p>
@@ -119,8 +119,8 @@ export default function AuthCheckPage() {
</div> </div>
</div> </div>
<button className="btn-secondary w-full"> <button className="btn-secondary w-full text-center">
Continue as Guest Continue as guest
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,31 +6,42 @@ import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; 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 { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns'; 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() { export default function ConfirmationPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const confirmAttempted = useRef(false);
const confirmMutation = useMutation({ const confirmMutation = useMutation({
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
}); });
const { data: _booking } = useQuery({ const { data: _booking } = useQuery<BookingWithTicket>({
queryKey: ['booking', bookingId], queryKey: ['booking', bookingId],
queryFn: async () => { queryFn: async (): Promise<BookingWithTicket> => {
try { try {
return await apiClient.get(`/bookings/${bookingId}`); return await apiClient.get(`/bookings/${bookingId}`);
} catch (error) { } catch (error) {
console.log('Booking API not available, using local data'); console.log('Booking API not available, using local data');
// Return mock booking data
return { return {
id: bookingId, id: bookingId || '',
pnr, pnr: pnr || undefined,
status: 'CONFIRMED', status: 'CONFIRMED',
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
}; };
@@ -40,10 +51,15 @@ export default function ConfirmationPage() {
}); });
useEffect(() => { useEffect(() => {
if (bookingId && !confirmMutation.isSuccess && !confirmMutation.isPending) { if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true;
confirmMutation.mutate(); confirmMutation.mutate();
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
console.error('Failed to generate ticket:', err);
});
} }
}, [bookingId]); }, [bookingId, confirmMutation]);
const copyPNR = () => { const copyPNR = () => {
if (pnr) { if (pnr) {
@@ -54,7 +70,6 @@ export default function ConfirmationPage() {
}; };
const handleDownloadTickets = () => { const handleDownloadTickets = () => {
// Mock download - in production this would call the API
alert('Ticket download will be available soon. Your tickets are displayed below.'); alert('Ticket download will be available soon. Your tickets are displayed below.');
}; };
@@ -71,15 +86,18 @@ export default function ConfirmationPage() {
router.push('/booking/search'); router.push('/booking/search');
}; };
if (!bookingId || !pnr) { useEffect(() => {
router.push('/booking/search'); if (!bookingId || !pnr) {
return null; router.push('/booking/search');
} }
}, [bookingId, pnr, router]);
if (!bookingId || !pnr) return null;
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Success Header */} {/* Success Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
@@ -87,14 +105,14 @@ export default function ConfirmationPage() {
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" /> <CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div> </div>
</div> </div>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking Confirmed!</h1> <h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking confirmed!</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p> <p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
</div> </div>
{/* PNR Card */} {/* PNR Card */}
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white"> <div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white">
<div className="text-center"> <div className="text-center">
<p className="text-sm opacity-90 mb-2">Booking Reference (PNR)</p> <p className="text-sm opacity-90 mb-2">Booking reference (PNR)</p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<span className="text-5xl font-bold tracking-widest">{pnr}</span> <span className="text-5xl font-bold tracking-widest">{pnr}</span>
<button <button
@@ -119,12 +137,12 @@ export default function ConfirmationPage() {
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary dark:text-primary-400" /> <Train className="w-6 h-6 text-primary dark:text-primary-400" />
</div> </div>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip Details</h2> <h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip details</h2>
</div> </div>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="text-sm text-gray-600 dark:text-gray-400">Train Number</p> <p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p> <p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
</div> </div>
<div> <div>
@@ -161,11 +179,12 @@ export default function ConfirmationPage() {
{/* Tickets */} {/* Tickets */}
<div className="mb-6"> <div className="mb-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your Tickets</h2> <h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
<div className="space-y-4"> <div className="space-y-4">
{passengers.map((passenger, index) => { {passengers.map((passenger, index) => {
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; const backendTicket = _booking?.ticket || null;
const qrData = JSON.stringify({ const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
const qrData = backendTicket?.qrPayload || JSON.stringify({
pnr, pnr,
ticketNumber, ticketNumber,
passengerName: passenger.name, passengerName: passenger.name,
@@ -201,7 +220,7 @@ export default function ConfirmationPage() {
</div> </div>
<div> <div>
<p className="text-gray-600 dark:text-gray-400">Seat</p> <p className="text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p> <p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatNumber || 'Will be assigned'}</p>
</div> </div>
</div> </div>
@@ -263,7 +282,7 @@ export default function ConfirmationPage() {
onClick={handleNewBooking} onClick={handleNewBooking}
className="btn-primary w-full py-4 text-lg font-semibold" className="btn-primary w-full py-4 text-lg font-semibold"
> >
Book Another Trip Book another trip
</button> </button>
{/* Info Notices */} {/* Info Notices */}

View File

@@ -1,7 +1,5 @@
'use client'; 'use client';
export const dynamic = 'force-dynamic';
import { useForm, useFieldArray } from 'react-hook-form'; import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -29,10 +27,9 @@ const passengerSchema = z.object({
faydaSub: z.string().optional(), faydaSub: z.string().optional(),
formExpanded: z.boolean().optional(), formExpanded: z.boolean().optional(),
}).refine((data) => { }).refine((data) => {
// For non-Ethiopian passengers, passport number and country are required
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
return data.passportNumber && data.passportNumber.length > 0 && return data.passportNumber && data.passportNumber.length > 0 &&
data.passportCountry && data.passportCountry.length > 0; data.passportCountry && data.passportCountry.length > 0;
} }
return true; return true;
}, { }, {
@@ -49,12 +46,13 @@ type FormData = z.infer<typeof formSchema>;
export default function PassengersPage() { export default function PassengersPage() {
const router = useRouter(); const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore(); const { user, isAuthenticated, updateUser } = useAuthStore();
const [faydaEnabled, setFaydaEnabled] = useState(true); const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({}); const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [updatingUser, setUpdatingUser] = useState(false);
const [saving, setSaving] = 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); const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -94,26 +92,82 @@ export default function PassengersPage() {
} }
}; };
checkFaydaStatus(); checkFaydaStatus();
}, []);
if (isAuthenticated && user && searchCriteria?.nationality === 'ETHIOPIAN') { useEffect(() => {
if (user.faydaVerified && user.fullName && user.dateOfBirth) { if (isAuthenticated && user?.faydaVerified) {
setValue('passengers.0.name', user.fullName); setVerificationStatus({ 0: 'success' });
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' });
}
} }
}, [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]); }, [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) => { const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const response: any = await apiClient.post('/fayda/verification/start', { const response: any = await apiClient.post('/fayda/verification/start', {
purpose: 'PURCHASE', purpose: 'PURCHASE',
@@ -126,7 +180,7 @@ export default function PassengersPage() {
const height = 700; const height = 700;
const left = (window.screen.width - width) / 2; const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2; const top = (window.screen.height - height) / 2;
const popup = window.open( const popup = window.open(
authorizationUrl, authorizationUrl,
'FaydaVerification', 'FaydaVerification',
@@ -170,6 +224,19 @@ export default function PassengersPage() {
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
setSaving(true); setSaving(true);
try { 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) => ({ const passengerDetails = data.passengers.map((p, i) => ({
name: p.name, name: p.name,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
@@ -181,20 +248,29 @@ export default function PassengersPage() {
phone: p.phone, phone: p.phone,
email: p.email, email: p.email,
isPrimaryPassenger: i === 0, isPrimaryPassenger: i === 0,
passengerId: i === 0 && passengerId ? passengerId : undefined,
})); }));
const deviceId = typeof window !== 'undefined' const deviceId = typeof window !== 'undefined'
? (localStorage.getItem('deviceId') || crypto.randomUUID()) ? (localStorage.getItem('deviceId') || crypto.randomUUID())
: crypto.randomUUID(); : crypto.randomUUID();
await apiClient.post('/passengers/save-details', { await apiClient.post('/passengers/save-details', {
passengers: passengerDetails, passengers: passengerDetails,
userId: user?.id, userId: user?.id,
deviceId, deviceId,
}); });
setPassengers(passengerDetails); setPassengers(passengerDetails);
setCreateAccount(data.createAccount); 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'); router.push('/booking/seats');
} catch (error) { } catch (error) {
console.error('Failed to save passenger details:', error); console.error('Failed to save passenger details:', error);
@@ -204,16 +280,71 @@ export default function PassengersPage() {
} }
}; };
if (!searchCriteria) { useEffect(() => {
router.push('/booking/search'); if (!searchCriteria) {
return null; router.push('/booking/search');
}
}, [searchCriteria, router]);
if (!searchCriteria) return null;
if (nationalityMismatch && formInitialized) {
const searchLabel: Record<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-lg mx-auto">
<div className="card border-red-300 dark:border-red-700" id="nationality-mismatch">
<div className="flex items-start gap-4">
<div className="text-red-500 text-2xl mt-0.5"></div>
<div>
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2">Nationality Mismatch</h2>
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
You searched for an <strong>{searchLabel[searchCriteria.nationality] ?? searchCriteria.nationality}</strong> passenger,
but your account is registered as <strong>{user?.nationality}</strong>.
</p>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-5">
You cannot proceed with this booking. Please restart and select the correct nationality on the search page.
</p>
<button
onClick={() => {
clearBooking();
if (typeof window !== 'undefined') {
window.location.href = '/booking/search';
}
}}
className="btn-primary w-full flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Restart Booking
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
if (!formInitialized) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-lg mx-auto text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading passenger details...</p>
</div>
</div>
</div>
);
} }
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger Details</h1> <h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{fields.map((field, index) => { {fields.map((field, index) => {
@@ -232,7 +363,7 @@ export default function PassengersPage() {
Passenger {index + 1} {index === 0 && '(Primary)'} Passenger {index + 1} {index === 0 && '(Primary)'}
{index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'} {index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'}
<span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400"> <span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400">
({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'International'}) ({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'})
</span> </span>
</h3> </h3>
@@ -249,27 +380,17 @@ export default function PassengersPage() {
type="button" type="button"
onClick={() => openFaydaVerification(index)} onClick={() => openFaydaVerification(index)}
className="btn-primary flex items-center justify-center gap-2 mx-auto" className="btn-primary flex items-center justify-center gap-2 mx-auto"
disabled={updatingUser}
> >
{updatingUser ? ( <ExternalLink className="w-5 h-5" />
<Loader2 className="w-5 h-5 animate-spin" /> {isLoggedInNotVerified ? 'Verify with Fayda' : 'Verify with Fayda'}
) : ( </button>
<ExternalLink className="w-5 h-5" /> <button
)} type="button"
{updatingUser ? 'Updating Profile...' : 'Verify with Fayda'} onClick={() => toggleForm(index)}
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto"
>
Skip for now
</button> </button>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3">
Click to verify your Ethiopian national ID
</p>
{!isLoggedInNotVerified && (
<button
type="button"
onClick={() => toggleForm(index)}
className="text-sm text-gray-600 dark:text-gray-400 hover:underline mt-2"
>
Or enter details manually
</button>
)}
</div> </div>
) : showManualEntryLink ? ( ) : showManualEntryLink ? (
<div className="text-center py-8"> <div className="text-center py-8">
@@ -281,238 +402,261 @@ export default function PassengersPage() {
onClick={() => toggleForm(index)} onClick={() => toggleForm(index)}
className="btn-primary" className="btn-primary"
> >
Enter Details Manually Enter details manually
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{isEthiopian ? ( {isEthiopian ? (
<> <>
{status === 'success' && ( {status === 'success' && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4"> <div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2"> <p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> Verified with Fayda <CheckCircle className="w-4 h-4" /> Verified with Fayda
</p> </p>
</div> </div>
)} )}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per ID"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<select
{...register(`passengers.${index}.gender`)}
className="input-field"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input
{...register(`passengers.${index}.phone`)}
className="input-field"
placeholder="+251911234567"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className="input-field"
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div>
</>
) : (
<>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per passport"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<select
{...register(`passengers.${index}.gender`)}
className="input-field"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input
{...register(`passengers.${index}.phone`)}
className="input-field"
placeholder="+254712345678"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className="input-field"
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div>
<div className="border-t dark:border-gray-700 pt-4 mt-4">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input <input
{...register(`passengers.${index}.passportNumber`)} {...register(`passengers.${index}.name`)}
className="input-field" className="input-field"
placeholder="P1234567" placeholder="Full name as per ID"
value={passengers[index]?.name || ''}
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
/> />
{errors.passengers?.[index]?.passportNumber && ( {errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p> <p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input <input
{...register(`passengers.${index}.passportCountry`)} type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field" className="input-field"
placeholder="Djibouti" value={passengers[index]?.dateOfBirth || ''}
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
/> />
{errors.passengers?.[index]?.passportCountry && ( {errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p> <p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Authority</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<input <select
{...register(`passengers.${index}.passportIssuingAuthority`)} {...register(`passengers.${index}.gender`)}
className="input-field" className="input-field"
placeholder="Government of Djibouti" value={passengers[index]?.gender || ''}
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
value={passengers[index]?.nationality || ''}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issue Date</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input <input
type="date" {...register(`passengers.${index}.phone`)}
{...register(`passengers.${index}.passportIssueDate`)}
className="input-field" className="input-field"
placeholder="+251911234567"
value={passengers[index]?.phone || ''}
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Expiry Date</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input <input
type="date" type="email"
{...register(`passengers.${index}.passportExpiryDate`)} {...register(`passengers.${index}.email`)}
className="input-field" className="input-field"
placeholder="email@example.com"
value={passengers[index]?.email || ''}
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
/> />
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div> </div>
</div> </div>
</div> </>
</> ) : (
)} <>
</div> <div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per passport"
value={passengers[index]?.name || ''}
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
value={passengers[index]?.dateOfBirth || ''}
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<select
{...register(`passengers.${index}.gender`)}
className="input-field"
value={passengers[index]?.gender || ''}
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
value={passengers[index]?.nationality || ''}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input
{...register(`passengers.${index}.phone`)}
className="input-field"
placeholder="+254712345678"
value={passengers[index]?.phone || ''}
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className="input-field"
placeholder="email@example.com"
value={passengers[index]?.email || ''}
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div>
<div className="border-t dark:border-gray-700 pt-4 mt-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
<input
{...register(`passengers.${index}.passportNumber`)}
className="input-field"
placeholder="P1234567"
value={passengers[index]?.passportNumber || ''}
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
/>
{errors.passengers?.[index]?.passportNumber && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country / Authority *</label>
<input
{...register(`passengers.${index}.passportCountry`)}
className="input-field"
placeholder="e.g., Djibouti / Government of Djibouti"
value={passengers[index]?.passportCountry || ''}
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
/>
{errors.passengers?.[index]?.passportCountry && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issue Date</label>
<input
type="date"
{...register(`passengers.${index}.passportIssueDate`)}
className="input-field"
value={passengers[index]?.passportIssueDate || ''}
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Expiry Date</label>
<input
type="date"
{...register(`passengers.${index}.passportExpiryDate`)}
className="input-field"
value={passengers[index]?.passportExpiryDate || ''}
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
/>
</div>
</div>
</div>
</>
)}
</div>
)} )}
</div> </div>
); );
})} })}
<div className="card"> {!isAuthenticated && (
<label className="flex items-center gap-2 cursor-pointer"> <div className="card">
<input type="checkbox" {...register('createAccount')} className="w-4 h-4" /> <label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span> <input type="checkbox" {...register('createAccount')} className="w-4 h-4" />
</label> <span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span>
</div> </label>
</div>
)}
<div className="flex gap-4"> <div className="flex gap-4">
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}> <button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
Back Back
</button> </button>
<button type="submit" className="btn-primary flex-1" disabled={saving}> <button type="submit" className="btn-primary flex-1" disabled={saving}>
{saving ? 'Saving...' : 'Continue to Seat Selection'} {saving ? 'Saving...' : 'Continue to seat selection'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -160,10 +160,10 @@ export default function PaymentPage() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete Payment</h1> <h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6"> <p className="text-gray-600 dark:text-gray-400 mb-6">
Booking Reference: <span className="font-bold text-primary">{pnr}</span> Booking reference: <span className="font-bold text-primary">{pnr}</span>
</p> </p>
{/* Payment Processing Overlay */} {/* Payment Processing Overlay */}
@@ -173,14 +173,14 @@ export default function PaymentPage() {
{paymentMutation.isSuccess ? ( {paymentMutation.isSuccess ? (
<> <>
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" /> <CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment Successful!</h3> <h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment successful!</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p> <p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" /> <Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
</> </>
) : ( ) : (
<> <>
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" /> <Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing Payment</h3> <h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing payment</h3>
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p> <p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
</> </>
)} )}
@@ -190,7 +190,7 @@ export default function PaymentPage() {
{/* Order Summary */} {/* Order Summary */}
<div className="card mb-6"> <div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order Summary</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order summary</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Route</span> <span className="text-gray-600 dark:text-gray-400">Route</span>
@@ -212,8 +212,8 @@ export default function PaymentPage() {
</div> </div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-gray-100">Total Amount</span> <span className="text-gray-900 dark:text-gray-100">Total amount</span>
<span className="text-primary"> <span className="text-primary dark:text-gray-100">
ETB {(totalAmount / 100).toFixed(2)} ETB {(totalAmount / 100).toFixed(2)}
</span> </span>
</div> </div>
@@ -223,7 +223,7 @@ export default function PaymentPage() {
{/* Payment Methods */} {/* Payment Methods */}
<div className="card mb-6"> <div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select Payment Method</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select payment method</h2>
<div className="space-y-3"> <div className="space-y-3">
{paymentMethods.map((method) => { {paymentMethods.map((method) => {
const Icon = method.icon; const Icon = method.icon;
@@ -285,7 +285,7 @@ export default function PaymentPage() {
disabled={isProcessing} disabled={isProcessing}
className="btn-secondary w-full py-2" className="btn-secondary w-full py-2"
> >
Back to Review Back to review
</button> </button>
</div> </div>

View File

@@ -138,12 +138,12 @@ export default function ResultsPage() {
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" /> <Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div> </div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No Trains Found</h2> <h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8"> <p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains matching your search criteria. Try adjusting your dates or route. We couldn&apos;t find any trains matching your search criteria. <br /> Try adjusting your dates or route.
</p> </p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary"> <button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Modify Search Modify search
</button> </button>
</div> </div>
</div> </div>
@@ -153,18 +153,18 @@ export default function ResultsPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8 md:py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<button <button
onClick={() => router.push(buildSearchUrl())} onClick={() => router.push(buildSearchUrl())}
className="btn-ghost mb-4 flex items-center gap-2" className="btn-ghost px-0 py-4 flex items-center gap-2"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
Modify Search Modify search
</button> </button>
<h1 className="section-title">Available Trains</h1> <h1 className="section-title">Available trains</h1>
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3"> <div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
@@ -202,7 +202,7 @@ export default function ResultsPage() {
<div className="flex flex-col lg:flex-row lg:items-center gap-6"> <div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<Clock className="w-5 h-5 text-primary" /> <Clock className="w-5 h-5 text-primary" />
</div> </div>
<div> <div>
@@ -267,7 +267,7 @@ export default function ResultsPage() {
onClick={() => toggleExpanded(scheduleId)} onClick={() => toggleExpanded(scheduleId)}
className="btn-secondary w-full flex items-center justify-center gap-2" className="btn-secondary w-full flex items-center justify-center gap-2"
> >
<span>View Classes</span> <span>Select class</span>
{isExpanded ? ( {isExpanded ? (
<ChevronUp className="w-4 h-4" /> <ChevronUp className="w-4 h-4" />
) : ( ) : (
@@ -295,9 +295,9 @@ export default function ResultsPage() {
disabled={!isAvailable} disabled={!isAvailable}
className={`relative p-4 rounded-lg border-2 text-left transition-all ${ className={`relative p-4 rounded-lg border-2 text-left transition-all ${
isSelected 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 : 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' : 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
}`} }`}
> >

View File

@@ -2,14 +2,57 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useState, useEffect } from 'react'; 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() { export default function ReviewPage() {
const router = useRouter(); 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<string>(''); const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({}); const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
@@ -62,7 +105,10 @@ export default function ReviewPage() {
}, [selectedSchedule?.id, passengers]); }, [selectedSchedule?.id, passengers]);
const createBookingMutation = useMutation({ 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) => { onSuccess: (data: any) => {
console.log('Booking created successfully:', data); console.log('Booking created successfully:', data);
const bookingIdValue = data.bookingId || data.id; const bookingIdValue = data.bookingId || data.id;
@@ -70,30 +116,25 @@ export default function ReviewPage() {
console.log('Setting booking ID:', bookingIdValue); console.log('Setting booking ID:', bookingIdValue);
console.log('Setting PNR:', pnrValue); console.log('Setting PNR:', pnrValue);
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
setBookingId(bookingIdValue); setBookingId(bookingIdValue);
setPNR(pnrValue); setPNR(pnrValue);
// Check if payment is required const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
const totalAmount = data.totalMinor || data.totalAmount || 0;
console.log('Total amount:', totalAmount); console.log('Total amount:', totalAmount);
console.log('Booking store after update:', useBookingStore.getState());
// Use setTimeout to ensure state updates complete before navigation
setTimeout(() => { setTimeout(() => {
// Verify state was set
const currentState = useBookingStore.getState(); const currentState = useBookingStore.getState();
console.log('Current booking store state:', currentState); console.log('Current booking store state:', currentState);
console.log('bookingId:', currentState.bookingId); console.log('bookingId:', currentState.bookingId);
console.log('pnr:', currentState.pnr); console.log('pnr:', currentState.pnr);
if (totalAmount > 0) { if (totalAmount > 0) {
// Redirect to payment page
console.log('Redirecting to payment page'); console.log('Redirecting to payment page');
router.push('/booking/payment'); router.push('/booking/payment');
} else { } else {
// No payment required, go directly to confirmation
console.log('Redirecting to confirmation page'); console.log('Redirecting to confirmation page');
router.push('/booking/confirmation'); router.push('/booking/confirmation');
} }
@@ -116,7 +157,6 @@ export default function ReviewPage() {
console.log('Selected schedule:', selectedSchedule); console.log('Selected schedule:', selectedSchedule);
console.log('Passengers:', passengers); console.log('Passengers:', passengers);
// Validate that we have a hold
if (!seatHold?.holdId) { if (!seatHold?.holdId) {
console.error('No seat hold found'); console.error('No seat hold found');
alert('Please select seats before continuing.'); alert('Please select seats before continuing.');
@@ -124,7 +164,6 @@ export default function ReviewPage() {
return; return;
} }
// Validate search criteria
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
console.error('Missing search criteria'); console.error('Missing search criteria');
alert('Missing search criteria. Please start over.'); alert('Missing search criteria. Please start over.');
@@ -132,7 +171,6 @@ export default function ReviewPage() {
return; return;
} }
// Get seat class ID
let seatClassId = 'default-seat-class-id'; let seatClassId = 'default-seat-class-id';
try { try {
const seatClasses: any = await apiClient.get('/seat-classes'); const seatClasses: any = await apiClient.get('/seat-classes');
@@ -144,37 +182,106 @@ export default function ReviewPage() {
console.error('Failed to fetch seat classes:', err); console.error('Failed to fetch seat classes:', err);
} }
const bookingData = { let bookingData: any;
scheduleId: selectedSchedule?.id || '', if (isAuthenticated) {
holdId: seatHold.holdId, // For authenticated users: get passengerId from multiple sources
originStationId: searchCriteria.originStationId, const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId, if (!token) {
displayCurrency: 'ETB' as const, console.error('No token in localStorage');
passengers: passengers.map(p => { throw new Error('Authentication token not found. Please log in again.');
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,
};
// Save deviceId for future use console.log('Token found, length:', token.length);
if (typeof window !== 'undefined' && bookingData.deviceId && !localStorage.getItem('deviceId')) {
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); localStorage.setItem('deviceId', bookingData.deviceId);
} }
@@ -182,11 +289,10 @@ export default function ReviewPage() {
await createBookingMutation.mutateAsync(bookingData); await createBookingMutation.mutateAsync(bookingData);
} catch (error) { } catch (error) {
console.error('Error in handleConfirm:', 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(() => { useEffect(() => {
if (!selectedSchedule || !passengers.length) { if (!selectedSchedule || !passengers.length) {
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
@@ -200,14 +306,11 @@ export default function ReviewPage() {
return null; return null;
} }
// Debug: Log selected schedule data
console.log('Selected schedule:', selectedSchedule); console.log('Selected schedule:', selectedSchedule);
console.log('Base fare adult:', selectedSchedule.baseFareAdult); console.log('Base fare adult:', selectedSchedule.baseFareAdult);
console.log('Passengers:', passengers); console.log('Passengers:', passengers);
// Calculate fare - use the fare from selected schedule or from fare breakdown
const baseFare = passengers.reduce((sum, p, i) => { const baseFare = passengers.reduce((sum, p, i) => {
// Get the fare per passenger from the schedule
const farePerPassenger = selectedSchedule.baseFareAdult || const farePerPassenger = selectedSchedule.baseFareAdult ||
(selectedSchedule as any).fareAdult || (selectedSchedule as any).fareAdult ||
(selectedSchedule as any).price || (selectedSchedule as any).price ||
@@ -215,8 +318,6 @@ export default function ReviewPage() {
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); 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; return sum + farePerPassenger;
}, 0); }, 0);
@@ -227,8 +328,8 @@ export default function ReviewPage() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review Your Booking</h1> <h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review your booking</h1>
{seatHold && ( {seatHold && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
@@ -240,7 +341,7 @@ export default function ReviewPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="card"> <div className="card">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip Details</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip details</h2>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Train</span> <span className="text-gray-600 dark:text-gray-400">Train</span>
@@ -290,15 +391,15 @@ export default function ReviewPage() {
</div> </div>
<div className="card"> <div className="card">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare Breakdown</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Base Fare</span> <span className="text-gray-600 dark:text-gray-400">Base fare</span>
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span> <span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2"> <div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
<span className="text-gray-900 dark:text-gray-100">Total</span> <span className="text-gray-900 dark:text-gray-100">Total</span>
<span className="text-gray-900 dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span> <span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -312,7 +413,7 @@ export default function ReviewPage() {
disabled={createBookingMutation.isPending} disabled={createBookingMutation.isPending}
className="btn-primary flex-1" className="btn-primary flex-1"
> >
{createBookingMutation.isPending ? 'Creating Booking...' : 'Confirm & Pay'} {createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
</button> </button>
</div> </div>

View File

@@ -5,11 +5,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types'; import { Station } from '@/types';
import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react'; import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker'; import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({ const searchSchema = z.object({
@@ -30,6 +31,8 @@ export default function SearchPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const { user, isAuthenticated } = useAuthStore();
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const { data: stations, isLoading, error } = useQuery<Station[]>({ const { data: stations, isLoading, error } = useQuery<Station[]>({
queryKey: ['stations'], 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(() => { useEffect(() => {
const origin = searchParams.get('origin'); const origin = searchParams.get('origin');
const destination = searchParams.get('destination'); const destination = searchParams.get('destination');
@@ -67,19 +82,9 @@ export default function SearchPage() {
}, [searchParams, setValue]); }, [searchParams, setValue]);
const originId = watch('originStationId'); const originId = watch('originStationId');
const destinationId = watch('destinationStationId');
const adultCount = watch('adultCount'); const adultCount = watch('adultCount');
const childCount = watch('childCount'); 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) => { const onSubmit = (data: SearchForm) => {
setSearchCriteria(data); setSearchCriteria(data);
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -117,11 +122,13 @@ export default function SearchPage() {
{ from: 'Diredawa', to: 'Nagad', duration: '4h' }, { from: 'Diredawa', to: 'Nagad', duration: '4h' },
]; ];
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Search Section */} {/* Search Section */}
<div className="container mx-auto px-4 py-8 md:py-12"> <div className="container mx-auto px-4 py-8 md:py-12">
<div className="max-w-5xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Search Card */} {/* Search Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-visible"> <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-visible">
{/* Header inside card */} {/* Header inside card */}
@@ -144,17 +151,19 @@ export default function SearchPage() {
)} )}
<form onSubmit={handleSubmit(onSubmit)} className="p-6 md:p-8"> <form onSubmit={handleSubmit(onSubmit)} className="p-6 md:p-8">
<div className="grid md:grid-cols-[1fr_auto_1fr] gap-4 items-end mb-6"> {/* First Row: From, To, Date */}
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label> {/* From */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">From</label>
<div className="relative"> <div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" /> <MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select <select
{...register('originStationId')} {...register('originStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading} disabled={isLoading}
> >
<option value="">Select departure station</option> <option value="">Select departure</option>
{stations?.map((s) => ( {stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
@@ -165,25 +174,17 @@ export default function SearchPage() {
)} )}
</div> </div>
<button {/* To */}
type="button" <div className="space-y-2 md:col-span-1">
onClick={swapStations} <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">To</label>
className="hidden md:flex items-center justify-center w-10 h-10 rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 hover:border-primary transition-all mb-2"
title="Swap stations"
>
<ArrowLeftRight className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">To</label>
<div className="relative"> <div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" /> <MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select <select
{...register('destinationStationId')} {...register('destinationStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading} disabled={isLoading}
> >
<option value="">Select arrival station</option> <option value="">Select arrival</option>
{stations?.map((s) => ( {stations?.map((s) => (
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option> <option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
))} ))}
@@ -193,11 +194,10 @@ export default function SearchPage() {
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p> <p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
)} )}
</div> </div>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-6"> {/* Date */}
<div className="space-y-2 relative z-10"> <div className="space-y-2 relative z-30 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Departure Date</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
<ModernDatePicker <ModernDatePicker
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined} value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
onChange={(date) => { onChange={(date) => {
@@ -207,98 +207,142 @@ export default function SearchPage() {
setValue('departureDate', `${year}-${month}-${day}`); setValue('departureDate', `${year}-${month}-${day}`);
}} }}
minDate={new Date()} minDate={new Date()}
placeholder="Select departure date" placeholder="Select date"
/> />
{errors.departureDate && ( {errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p> <p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)} )}
</div> </div>
</div>
{/* Second Row: Passengers, Nationality, Promo Code */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* Passengers Dropdown */}
<div className="space-y-2 relative z-20">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Passengers</label>
<button
type="button"
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 flex items-center justify-between hover:border-primary transition-colors"
>
<span className="flex items-center gap-2">
<Users className="w-4 h-4 text-primary" />
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
</span>
<ChevronDown className={`w-4 h-4 transition-transform text-primary ${isPassengerOpen ? 'rotate-180' : ''}`} />
</button>
{/* Passenger Dropdown Menu */}
{isPassengerOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg z-50 p-4 space-y-4">
{/* Adults */}
<div>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current > 1) setValue('adultCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) <= 1}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
{/* Children */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Nationality */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Passengers</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Nationality</label>
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-700"> <select {...register('nationality')} className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<div className="flex items-center justify-between mb-3"> <option value="ETHIOPIAN">Ethiopian</option>
<div> <option value="DJIBOUTIAN">Djiboutian</option>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div> <option value="OTHER">Other</option>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div> </select>
</div> </div>
<div className="flex items-center gap-3">
<button {/* Promo Code */}
type="button" <div className="space-y-2">
onClick={() => { <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
const current = adultCount || 1; <input
if (current > 1) setValue('adultCount', current - 1); type="text"
}} placeholder="Enter promo code"
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50" className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
disabled={(adultCount || 1) <= 1} />
>
<Minus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
<span className="w-8 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-600">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First child free</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
<span className="w-8 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="space-y-2 mb-6"> {/* Third Row: Search Button */}
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label> <div>
<select {...register('nationality')} className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"> <button type="submit" className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(16_89_60)] text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl">
<option value="ETHIOPIAN">Ethiopian</option> <Search className="w-5 h-5 text-white" />
<option value="DJIBOUTIAN">Djiboutian</option> <span>Search Train</span>
<option value="OTHER">Other</option> </button>
</select>
</div> </div>
<button type="submit" className="w-full bg-primary hover:bg-primary-700 text-white font-semibold py-4 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 text-lg shadow-lg hover:shadow-xl">
<Search className="w-5 h-5" />
<span>Search Trains</span>
</button>
</form> </form>
</div> </div>
{/* Popular Routes */}
<div className="mt-12"> <div className="mt-12">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Popular Routes</h2> <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Popular Routes</h2>
<div className="grid md:grid-cols-3 gap-4"> <div className="grid md:grid-cols-3 gap-4">
@@ -312,7 +356,7 @@ export default function SearchPage() {
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1"> <div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div> <div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
<ArrowRight className="w-4 h-4 text-gray-400 dark:text-gray-500 my-2" /> <ArrowRight className="w-4 h-4 text-primary my-2" />
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div> <div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
</div> </div>
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" /> <Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
@@ -323,29 +367,6 @@ export default function SearchPage() {
</div> </div>
</div> </div>
<div className="mt-12 grid md:grid-cols-3 gap-6">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<Train className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Modern Fleet</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Comfortable trains with modern amenities</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<MapPin className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">21 Stations</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Connecting Ethiopia and Djibouti</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<Calendar className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Easy Booking</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Book tickets in just a few clicks</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,10 +6,36 @@ import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, useMemo, memo } from 'react';
import CustomModal from '@/components/CustomModal'; 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 (
<button
onClick={() => onToggle(seat.id)}
disabled={seat.status !== 'AVAILABLE'}
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${
isSelected
? 'bg-primary text-white shadow-md scale-105'
: seat.status === 'AVAILABLE'
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md cursor-pointer'
: seat.status === 'HELD'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
}`}
title={`Seat ${seatLabel} - ${seat.status}`}
>
{seatLabel}
</button>
);
});
SeatButton.displayName = 'SeatButton';
export default function SeatsPage() { export default function SeatsPage() {
const router = useRouter(); const router = useRouter();
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore(); const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
@@ -29,20 +55,10 @@ export default function SeatsPage() {
enabled: !!selectedSchedule?.id, 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({ const holdMutation = useMutation({
mutationFn: async (seatIds: string[]) => { mutationFn: async (seatIds: string[]) => {
// Create temporary passenger IDs for the hold
const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({ 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], seatId: seatIds[i],
})); }));
@@ -61,47 +77,21 @@ export default function SeatsPage() {
}, },
}); });
// Extract coaches and seats from seat map data const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
const coaches = (seatMapData as any)?.coaches || [];
// Debug: Log coaches const filteredCoaches = useMemo(() => {
useEffect(() => { return selectedSchedule?.selectedSeatClass
console.log('Coaches:', coaches); ? coaches.filter((c: any) => {
console.log('Selected seat class:', selectedSchedule?.selectedSeatClass); const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
if (coaches.length > 0) { return seatClassName === selectedSchedule.selectedSeatClass ||
console.log('First coach structure:', coaches[0]); seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
console.log('First coach seatClass:', coaches[0]?.seatClass); seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
console.log('First coach coachClass:', coaches[0]?.coachClass); })
} : coaches;
}, [coaches, selectedSchedule?.selectedSeatClass]); }, [coaches, selectedSchedule?.selectedSeatClass]);
// Filter coaches by selected seat class if available const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
const filteredCoaches = selectedSchedule?.selectedSeatClass const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
? 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]);
useEffect(() => { useEffect(() => {
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) { if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
@@ -109,21 +99,28 @@ export default function SeatsPage() {
} }
}, [filteredCoaches, selectedCoach]); }, [filteredCoaches, selectedCoach]);
const toggleSeat = (seatId: string) => { const toggleSeat = useCallback((seatId: string) => {
if (selectedSeats.includes(seatId)) { setSelectedSeats(prev => {
setSelectedSeats(selectedSeats.filter(id => id !== seatId)); if (prev.includes(seatId)) {
} else if (selectedSeats.length < passengers.length) { return prev.filter(id => id !== seatId);
setSelectedSeats([...selectedSeats, seatId]); } else if (prev.length < passengers.length) {
} return [...prev, seatId];
}; }
return prev;
});
}, [passengers.length]);
const handleContinue = async () => { const handleContinue = async () => {
if (selectedSeats.length > 0) { if (selectedSeats.length > 0) {
await holdMutation.mutateAsync(selectedSeats); await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => ({ const updatedPassengers = passengers.map((p, i) => {
...p, const seatData = seats?.find((s: any) => s.id === selectedSeats[i]);
seatId: selectedSeats[i], return {
})); ...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
} }
router.push('/booking/review'); router.push('/booking/review');
@@ -146,10 +143,14 @@ export default function SeatsPage() {
try { try {
await holdMutation.mutateAsync(autoSelectedSeats); await holdMutation.mutateAsync(autoSelectedSeats);
const updatedPassengers = passengers.map((p, i) => ({ const updatedPassengers = passengers.map((p, i) => {
...p, const seatData = availableSeats[i];
seatId: autoSelectedSeats[i], return {
})); ...p,
seatId: autoSelectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
router.push('/booking/review'); router.push('/booking/review');
} catch (error: any) { } catch (error: any) {
@@ -163,10 +164,13 @@ export default function SeatsPage() {
} }
}; };
if (!selectedSchedule || !passengers.length) { useEffect(() => {
router.push('/booking/search'); if (!selectedSchedule || !passengers.length) {
return null; router.push('/booking/search');
} }
}, [selectedSchedule, passengers.length, router]);
if (!selectedSchedule || !passengers.length) return null;
return ( return (
<> <>
@@ -180,12 +184,12 @@ export default function SeatsPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select Seats</h1> <h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
<div className="grid lg:grid-cols-3 gap-6"> <div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="card mb-4"> <div className="card mb-4">
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select Coach</h3> <h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
{selectedSchedule?.selectedSeatClassName && ( {selectedSchedule?.selectedSeatClassName && (
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400"> <div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span> Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
@@ -215,7 +219,7 @@ export default function SeatsPage() {
</div> </div>
<div className="card"> <div className="card">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat Map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3> <h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Loading seats...</p> <p>Loading seats...</p>
@@ -228,35 +232,21 @@ export default function SeatsPage() {
) : seats.length === 0 ? ( ) : seats.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p> <p>No seats available in this coach</p>
<p className="text-sm mt-2">Please select a different coach</p> <p className="text-sm mt-2\">Please select a different coach</p>
</div> </div>
) : ( ) : (
<> <>
{/* Seat Grid */} {/* Seat Grid */}
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto"> <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto">
<div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}> <div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}>
{seats?.map((seat: any) => { {seats?.map((seat: any) => (
const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; <SeatButton
return ( key={seat.id}
<button seat={seat}
key={seat.id} isSelected={selectedSeats.includes(seat.id)}
onClick={() => seat.status === 'AVAILABLE' && toggleSeat(seat.id)} onToggle={toggleSeat}
disabled={seat.status !== 'AVAILABLE'} />
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${ ))}
selectedSeats.includes(seat.id)
? 'bg-primary text-white shadow-md scale-105'
: seat.status === 'AVAILABLE'
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md'
: seat.status === 'HELD'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
}`}
title={`Seat ${seatLabel} - ${seat.status}`}
>
{seatLabel}
</button>
);
})}
</div> </div>
</div> </div>
@@ -286,7 +276,7 @@ export default function SeatsPage() {
<div> <div>
<div className="card sticky top-4"> <div className="card sticky top-4">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection Summary</h3> <h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select {passengers.length} seat(s) for your passengers Select {passengers.length} seat(s) for your passengers
</p> </p>
@@ -314,14 +304,14 @@ export default function SeatsPage() {
disabled={selectedSeats.length === 0} disabled={selectedSeats.length === 0}
className="btn-primary w-full mb-2" className="btn-primary w-full mb-2"
> >
Continue with Selected Seats Continue with selected seats
</button> </button>
<button <button
onClick={handleAutoAssign} onClick={handleAutoAssign}
disabled={holdMutation.isPending} disabled={holdMutation.isPending}
className="btn-secondary w-full" className="btn-secondary w-full"
> >
{holdMutation.isPending ? 'Assigning...' : 'Auto-Assign Seats'} {holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -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<Language>('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 (
<>
<style>{styles}</style>
<main>
<section className="contact-hero">
<h1>{t('contact.title')}</h1>
<p>{t('contact.subtitle')}</p>
</section>
<section className="contact-grid">
{contactInfo.map((info, idx) => {
const Icon = info.icon;
return (
<a key={idx} href={info.link} className="contact-card">
<div className="contact-icon">
<Icon size={24} color="white" />
</div>
<h3>{info.title}</h3>
<p>{info.value}</p>
</a>
);
})}
</section>
<section className="form-section">
<div className="form-container">
<h2>{t('contact.form')}</h2>
{message && (
<div className={`alert alert-${message.type === 'success' ? 'success' : 'error'}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>{t('contact.name')}</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.emailField')}</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.subject')}</label>
<input
type="text"
required
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.message')}</label>
<textarea
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
/>
</div>
<button type="submit" disabled={loading} className="form-submit">
{loading ? (
<>
<Loader size={18} />
{t('contact.sending')}
</>
) : (
<>
<Send size={18} />
{t('contact.send')}
</>
)}
</button>
</form>
</div>
</section>
</main>
</>
);
}

View File

@@ -10,27 +10,47 @@
@layer components { @layer components {
.btn-primary { .btn-primary {
@apply bg-primary hover:bg-primary-700 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transform hover:-translate-y-0.5; @apply inline-flex items-center justify-center gap-2 px-6 py-3 bg-[rgb(20_113_76)] text-white font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
}
.btn-primary:hover {
@apply bg-[rgb(16_89_60)];
} }
.btn-secondary { .btn-secondary {
@apply bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 shadow-md hover:shadow-lg; @apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-gray-200 dark:border-gray-700 shadow-md hover:shadow-lg;
}
.btn-secondary:hover {
@apply bg-gray-50 dark:bg-gray-700 border-[rgb(20_113_76)] dark:border-[rgb(20_113_76)];
} }
.btn-ghost { .btn-ghost {
@apply text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 font-medium py-2 px-4 rounded-lg transition-colors; @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
}
.btn-ghost:hover {
@apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
} }
.input-field { .input-field {
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100; @apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
} }
.card { .card {
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border border-gray-100 dark:border-gray-700 hover:shadow-md transition-shadow duration-200; @apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border border-gray-100 dark:border-gray-700;
}
.card:hover {
@apply shadow-md;
} }
.card-interactive { .card-interactive {
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border-2 border-gray-100 dark:border-gray-700 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer; @apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border-2 border-gray-100 dark:border-gray-700 cursor-pointer transition-all duration-200;
}
.card-interactive:hover {
@apply border-[rgb(20_113_76)] shadow-lg;
} }
.badge { .badge {
@@ -62,4 +82,79 @@
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
@keyframes bounce-in {
0% {
opacity: 0;
transform: translateY(20px) scale(0.9);
}
50% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-shimmer {
background-size: 1000px 100%;
animation: shimmer 2s infinite;
}
.animate-slide-in-left {
animation: slide-in-left 0.5s ease-out;
}
.animate-slide-in-right {
animation: slide-in-right 0.5s ease-out;
}
} }

View File

@@ -88,7 +88,7 @@ export default function HowToGuidePage() {
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter Passenger Details</h3> <h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter passenger details</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3"> <p className="text-gray-600 dark:text-gray-400 mb-3">
You can sign in for a faster experience or continue as a guest. You can sign in for a faster experience or continue as a guest.
</p> </p>
@@ -113,7 +113,7 @@ export default function HowToGuidePage() {
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select Seats</h3> <h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select seats</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3"> <p className="text-gray-600 dark:text-gray-400 mb-3">
Choose your preferred seats from the interactive seat map. Available seats are shown in green. Choose your preferred seats from the interactive seat map. Available seats are shown in green.
</p> </p>

View File

@@ -0,0 +1,424 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { ChevronDown, Search, MessageCircle } from 'lucide-react';
interface FAQItem {
question: string;
answer: string;
}
interface FAQCategory {
title: string;
items: FAQItem[];
}
const styles = `
.help-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .help-hero {
color: #f3f4f6;
}
.help-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .help-hero h1 {
color: #f3f4f6;
}
.help-hero p {
font-size: 1.125rem;
color: #6b7280;
}
.dark .help-hero p {
color: #9ca3af;
}
.search-section {
padding: 30px 20px;
background-color: white;
border-bottom: 1px solid #e5e7eb;
}
.dark .search-section {
background-color: #111827;
border-bottom-color: #374151;
}
.search-box {
max-width: 42rem;
margin: 0 auto;
position: relative;
}
.search-box input {
width: 100%;
padding: 12px 40px 12px 12px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s;
box-sizing: border-box;
background: white;
color: #111827;
}
.dark .search-box input {
background: #1f2937;
color: #f3f4f6;
border-color: #374151;
}
.search-box input:focus {
outline: none;
border-color: rgb(20, 113, 76);
box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1);
}
.search-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.faq-section {
padding: 60px 20px;
background-color: white;
}
.dark .faq-section {
background-color: #111827;
}
.faq-container {
max-width: 48rem;
margin: 0 auto;
}
.faq-category {
margin-bottom: 40px;
}
.faq-category h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .faq-category h2 {
color: #f3f4f6;
}
.faq-item {
border: 2px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
transition: all 0.2s;
}
.dark .faq-item {
border-color: #374151;
}
.faq-item:hover {
border-color: rgb(20, 113, 76);
}
.faq-question {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: white;
cursor: pointer;
transition: all 0.2s;
border: none;
width: 100%;
text-align: left;
font-weight: 600;
color: #111827;
}
.dark .faq-question {
background-color: #1f2937;
color: #f3f4f6;
}
.faq-question:hover {
background-color: #f9fafb;
}
.dark .faq-question:hover {
background-color: #374151;
}
.faq-chevron {
transition: transform 0.3s;
flex-shrink: 0;
margin-left: 12px;
}
.faq-chevron.open {
transform: rotate(180deg);
}
.faq-answer {
padding: 16px;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 0.875rem;
line-height: 1.6;
}
.dark .faq-answer {
background-color: #0f1117;
border-top-color: #374151;
color: #9ca3af;
}
.help-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .help-section {
background-color: #0f1117;
}
.help-card {
max-width: 42rem;
margin: 0 auto;
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
text-align: center;
}
.dark .help-card {
background: #1f2937;
border-color: #374151;
}
.help-icon {
width: 64px;
height: 64px;
background: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.help-card h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 12px;
color: #111827;
}
.dark .help-card h2 {
color: #f3f4f6;
}
.help-card p {
color: #6b7280;
margin-bottom: 24px;
}
.dark .help-card p {
color: #9ca3af;
}
.button-primary {
display: inline-block;
padding: 12px 32px;
background-color: rgb(20, 113, 76);
color: white;
font-weight: 700;
border-radius: 12px;
text-decoration: none;
transition: all 0.2s;
}
.button-primary:hover {
background-color: rgb(16, 89, 60);
transform: scale(1.05);
}
.no-results {
text-align: center;
padding: 40px 20px;
color: #6b7280;
}
.dark .no-results {
color: #9ca3af;
}
@media (max-width: 768px) {
.help-hero h1 {
font-size: 1.875rem;
}
}
`;
export default function Help() {
const [lang, setLang] = useState<Language>('en');
const [openIndexes, setOpenIndexes] = useState<number[]>([]);
const [searchTerm, setSearchTerm] = useState('');
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 faqCategories: FAQCategory[] = [
{
title: t('help.bookingFaq'),
items: [
{ question: t('help.how'), answer: t('help.howAnswer') },
{ question: t('help.modify'), answer: t('help.modifyAnswer') },
{ question: t('help.cancel'), answer: t('help.cancelAnswer') },
],
},
{
title: t('help.paymentFaq'),
items: [
{ question: t('help.payMethods'), answer: t('help.payMethodsAnswer') },
{ question: t('help.refund'), answer: t('help.refundAnswer') },
],
},
{
title: t('help.other'),
items: [
{ question: t('help.docs'), answer: t('help.docsAnswer') },
],
},
];
const toggleFAQ = (index: number) => {
if (openIndexes.includes(index)) {
setOpenIndexes(openIndexes.filter(i => i !== index));
} else {
setOpenIndexes([...openIndexes, index]);
}
};
let flatFAQs: (FAQItem & { id: number })[] = [];
faqCategories.forEach((cat) => {
cat.items.forEach((item) => {
flatFAQs.push({ ...item, id: flatFAQs.length });
});
});
const filteredFAQs = flatFAQs.filter(
(faq) =>
faq.question.toLowerCase().includes(searchTerm.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<style>{styles}</style>
<main>
<section className="help-hero">
<h1>{t('help.title')}</h1>
<p>{t('help.subtitle')}</p>
</section>
<section className="search-section">
<div className="search-box">
<input
type="text"
placeholder="Search FAQs..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Search className="search-icon" size={20} />
</div>
</section>
<section className="faq-section">
<div className="faq-container">
{searchTerm ? (
<>
{filteredFAQs.length > 0 ? (
filteredFAQs.map((faq) => (
<div key={faq.id} className="faq-item">
<div className="faq-question">
<span>{faq.question}</span>
</div>
<div className="faq-answer">{faq.answer}</div>
</div>
))
) : (
<div className="no-results">
No FAQs found for &quot;{searchTerm}&quot;
</div>
)}
</>
) : (
faqCategories.map((category, catIdx) => (
<div key={catIdx} className="faq-category">
<h2>{category.title}</h2>
{category.items.map((item, itemIdx) => {
const globalIdx = catIdx * 100 + itemIdx;
const isOpen = openIndexes.includes(globalIdx);
return (
<div key={itemIdx} className="faq-item">
<button
className="faq-question"
onClick={() => toggleFAQ(globalIdx)}
>
<span>{item.question}</span>
<ChevronDown className={`faq-chevron ${isOpen ? 'open' : ''}`} size={20} color="rgb(20, 113, 76)" />
</button>
{isOpen && <div className="faq-answer">{item.answer}</div>}
</div>
);
})}
</div>
))
)}
</div>
</section>
<section className="help-section">
<div className="help-card">
<div className="help-icon">
<MessageCircle size={32} color="white" />
</div>
<h2>{t('help.help')}</h2>
<p>{t('help.contact')}</p>
<Link href="/contact" className="button-primary">Contact Support</Link>
</div>
</section>
</main>
</>
);
}

View File

@@ -2,9 +2,11 @@ import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { Providers } from './providers'; import { Providers } from './providers';
import AppHeader from '@/components/AppHeader'; import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book Your Train Journey', title: 'EDR Passenger Portal - Book your train journey',
description: 'Book train tickets on the Ethio-Djibouti Railway', description: 'Book train tickets on the Ethio-Djibouti Railway',
}; };
@@ -15,7 +17,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased"> <body className="font-sans antialiased flex flex-col min-h-screen">
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@@ -39,8 +41,12 @@ export default function RootLayout({
}} }}
/> />
<Providers> <Providers>
<LoadingIndicator />
<AppHeader /> <AppHeader />
{children} <main className="flex-1">
{children}
</main>
<Footer />
</Providers> </Providers>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
import { useState, Suspense } from 'react'; import { useState, Suspense } from 'react';
import { Train } from 'lucide-react'; import { Train } from 'lucide-react';
@@ -20,7 +19,6 @@ function LoginContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login); const login = useAuthStore((s) => s.login);
const { searchCriteria } = useBookingStore();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -43,14 +41,16 @@ function LoginContent() {
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4"> <div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<Train className="w-12 h-12 text-primary" /> <div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign In</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back to EDR Platform</p> <p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
</div> </div>
<div className="card"> <div className="card">
@@ -88,18 +88,13 @@ function LoginContent() {
</div> </div>
<button type="submit" className="btn-primary w-full" disabled={loading}> <button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign in'}
</button> </button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<button <button
onClick={() => { onClick={() => router.push('/booking/search')}
// If booking is started (search criteria exists), go back to passengers page
// Otherwise, go to booking search page
const destination = searchCriteria ? '/booking/passengers' : '/booking/search';
router.push(destination);
}}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400" className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
> >
Back to booking Back to booking
@@ -113,10 +108,12 @@ function LoginContent() {
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={ <Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<Train className="w-12 h-12 text-primary animate-pulse mx-auto mb-4" /> <div className="w-12 h-12 bg-white rounded-lg flex items-center justify-center mx-auto mb-4">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<p className="text-gray-600 dark:text-gray-400">Loading...</p> <p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,438 @@
import { redirect } from 'next/navigation'; 'use client';
export default function HomePage() { import { useEffect, useState } from 'react';
redirect('/booking/search'); import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { SearchWidget } from '@/components/SearchWidget';
import { Zap, Heart, Shield, Clock, ArrowRight, Train, MapPin, Calendar } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Station } from '@/types';
const styles = `
.hero-section {
position: relative;
overflow: hidden;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
padding: 80px 20px;
text-align: center;
color: #111827;
}
.dark .hero-section {
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
color: #f3f4f6;
}
.hero-content {
max-width: 100%;
margin: 0 auto;
padding: 0 20px;
}
.hero-heading {
font-size: clamp(1rem, 3vw, 2.75rem);
font-weight: 700;
margin-bottom: 24px;
color: #ffffff;
line-height: 1.2;
}
.dark .hero-heading {
color: #ffffff;
}
.hero-subheading {
font-size: clamp(1rem, 3vw, 1.5rem);
color: #4b5563;
margin-bottom: 32px;
}
.dark .hero-subheading {
color: #9ca3af;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 32px 16px;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
margin-top: 48px;
}
.dark .stats-grid {
border-top-color: #374151;
border-bottom-color: #374151;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: rgb(20, 113, 76);
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.dark .stat-label {
color: #9ca3af;
}
.features-section {
padding: 80px 20px;
background-color: #ffffff;
}
.dark .features-section {
background-color: #111827;
}
.section-title {
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 40px;
color: #111827;
}
.dark .section-title {
color: #f3f4f6;
}
.features-grid {
max-width: 72rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.feature-card {
background-color: white;
border: 2px solid #f3f4f6;
border-radius: 18px;
padding: 24px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-align: left;
}
.dark .feature-card {
background-color: #1f2937;
border-color: #374151;
}
.feature-card:hover {
border-color: rgb(20, 113, 76);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transform: translateY(-8px);
}
.feature-icon-bg {
width: 48px;
height: 48px;
background-color: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.feature-title {
font-weight: 700;
color: #111827;
margin-bottom: 8px;
font-size: 1.125rem;
}
.dark .feature-title {
color: #f3f4f6;
}
.feature-desc {
font-size: 0.875rem;
color: #6b7280;
}
.dark .feature-desc {
color: #9ca3af;
}
.cta-section {
padding: 80px 20px;
background: #f3f4f6;
text-align: center;
}
.dark .cta-section {
background: #111827;
}
.cta-content {
max-width: 42rem;
margin: 0 auto;
}
.cta-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .cta-title {
color: white;
}
.cta-text {
font-size: 1.125rem;
color: #4b5563;
margin-bottom: 32px;
}
.dark .cta-text {
color: #e0e7ff;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 16px 32px;
background-color: white;
color: rgb(20 113 76 / var(--tw-bg-opacity, 1));
font-weight: 700;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
text-decoration: none;
}
.cta-button:hover {
background-color: #f0f9ff;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
transform: scale(1.05);
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.bounce {
animation: bounce 2s infinite;
}
.bounce:nth-child(2) {
animation-delay: 0.2s;
}
.bounce:nth-child(3) {
animation-delay: 0.4s;
}
.search-widget-transparent {
background-color: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(10px);
border-color: rgba(255, 255, 255, 0.2) !important;
}
.dark .search-widget-transparent {
background-color: rgba(31, 41, 55, 0.95) !important;
border-color: rgba(55, 65, 81, 0.2) !important;
}
`;
export default function Home() {
const [lang, setLang] = useState<Language>('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 { data: stations } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const getStationByName = (name: string) => {
if (!stations) return null;
const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase());
if (exactMatch) return exactMatch;
return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase()));
};
const handlePopularRoute = (fromName: string, toName: string) => {
const origin = getStationByName(fromName);
const destination = getStationByName(toName);
if (origin && destination) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const popularRoutes = [
{ from: 'Sebeta', to: 'Nagad', duration: '12h' },
{ from: 'Sebeta', to: 'Diredawa', duration: '8h' },
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
];
const features = [
{
icon: Heart,
title: t('home.comfortable'),
desc: t('home.comfortDesc'),
},
{
icon: Zap,
title: t('home.affordable'),
desc: t('home.affordableDesc'),
},
{
icon: Shield,
title: t('home.safe'),
desc: t('home.safeDesc'),
},
{
icon: Clock,
title: t('home.fast'),
desc: t('home.fastDesc'),
},
];
const highlights = [
{
icon: Train,
title: 'Modern fleet',
desc: 'Comfortable trains with modern amenities',
},
{
icon: MapPin,
title: '21 stations',
desc: 'Connecting Ethiopia and Djibouti',
},
{
icon: Calendar,
title: 'Easy booking',
desc: 'Book tickets in just a few clicks',
},
];
return (
<>
<style>{styles}</style>
<main>
{/* Hero Section */}
<section className="hero-section">
<div className="hero-content">
<h1 className="hero-heading">{t('home.hero')}</h1>
<p className="hero-subheading">{t('home.heroSub')}</p>
<SearchWidget />
{/* Highlights */}
<div className="grid md:grid-cols-3 gap-6 mt-12 max-w-6xl mx-auto">
{highlights.map((highlight, idx) => {
const Icon = highlight.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{highlight.title}</h3>
<p className="feature-desc">{highlight.desc}</p>
</div>
);
})}
</div>
</div>
</section>
{/* Popular Routes Section */}
<section className="py-16 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto px-4">
<h2 className="section-title">Popular Routes</h2>
<div className="grid md:grid-cols-3 gap-4">
{popularRoutes.map((route, idx) => (
<button
key={idx}
type="button"
onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:border-primary hover:shadow-md transition-all text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
<ArrowRight className="w-4 h-4 text-primary my-2" />
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
</div>
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{route.duration} journey</div>
</button>
))}
</div>
</div>
</section>
{/* Features Section */}
<section className="features-section">
<h2 className="section-title">{t('home.features')}</h2>
<div className="features-grid">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-desc">{feature.desc}</p>
</div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className="cta-section">
<div className="cta-content">
<h2 className="cta-title">Ready to start your journey?</h2>
<p className="cta-text">Book your train tickets in just a few minutes and enjoy a comfortable ride.</p>
<Link href="/booking/search" className="cta-button">
{t('home.cta')}
<ArrowRight size={20} />
</Link>
</div>
</section>
</main>
</>
);
} }

View File

@@ -65,9 +65,7 @@ export default function ProfilePage() {
if (!isInitialized) return; if (!isInitialized) return;
if (isAuthenticated && user) { if (isAuthenticated && user) {
// Fetch fresh profile data
fetchProfile().catch(() => { fetchProfile().catch(() => {
// If fetch fails, redirect to login
router.push('/login?redirect=/profile'); router.push('/login?redirect=/profile');
}); });
@@ -200,7 +198,6 @@ export default function ProfilePage() {
message: 'Are you sure you want to sign out?', message: 'Are you sure you want to sign out?',
onConfirm: async () => { onConfirm: async () => {
await logout(); await logout();
// Navigation will be handled by logout function
}, },
}); });
setShowModal(true); setShowModal(true);
@@ -245,7 +242,7 @@ export default function ProfilePage() {
if (!isInitialized || !user) { if (!isInitialized || !user) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)]"></div>
</div> </div>
); );
} }
@@ -258,7 +255,7 @@ export default function ProfilePage() {
<div className="card mb-6"> <div className="card mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-[rgb(20_113_76)] rounded-full flex items-center justify-center">
<User className="w-8 h-8 text-white" /> <User className="w-8 h-8 text-white" />
</div> </div>
<div> <div>
@@ -289,7 +286,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('bookings')} onClick={() => setActiveTab('bookings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'bookings' activeTab === 'bookings'
? 'bg-primary text-white' ? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
> >
@@ -300,7 +297,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('profile')} onClick={() => setActiveTab('profile')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'profile' activeTab === 'profile'
? 'bg-primary text-white' ? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
> >
@@ -311,7 +308,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('settings')} onClick={() => setActiveTab('settings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'settings' activeTab === 'settings'
? 'bg-primary text-white' ? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
> >
@@ -328,7 +325,7 @@ export default function ProfilePage() {
{loadingBookings ? ( {loadingBookings ? (
<div className="card text-center py-12"> <div className="card text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p> <p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
</div> </div>
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? ( ) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
@@ -482,7 +479,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })} onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
className="sr-only peer" className="sr-only peer"
/> />
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div> <div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div> <div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div> </div>
</label> </label>
@@ -495,7 +492,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })}
className="sr-only peer" className="sr-only peer"
/> />
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div> <div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div> <div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div> </div>
</label> </label>
@@ -508,7 +505,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })} onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })}
className="sr-only peer" className="sr-only peer"
/> />
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div> <div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div> <div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div> </div>
</label> </label>

View File

@@ -0,0 +1,309 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { BookOpen, CreditCard, Headphones, Star, MapPin, Radio, ArrowRight } from 'lucide-react';
const styles = `
.service-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .service-hero {
color: #f3f4f6;
}
.service-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .service-hero h1 {
color: #f3f4f6;
}
.service-hero p {
font-size: 1.125rem;
color: #6b7280;
max-width: 48rem;
margin: 0 auto;
}
.dark .service-hero p {
color: #9ca3af;
}
.service-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
padding: 60px 20px;
background-color: white;
}
.dark .service-grid {
background-color: #111827;
}
.service-card {
background: white;
border: 2px solid #f3f4f6;
border-radius: 18px;
padding: 24px;
cursor: pointer;
transition: all 0.2s;
}
.dark .service-card {
background: #1f2937;
border-color: #374151;
}
.service-card:hover {
border-color: rgb(20, 113, 76);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
.service-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
margin-bottom: 16px;
}
.service-card h3 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 12px;
color: #111827;
}
.dark .service-card h3 {
color: #f3f4f6;
}
.service-card p {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 16px;
}
.dark .service-card p {
color: #9ca3af;
}
.service-card a {
color: rgb(20, 113, 76);
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.service-card a:hover {
text-decoration: underline;
}
.info-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .info-section {
background-color: #0f1117;
}
.info-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.info-box {
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
}
.dark .info-box {
background: #1f2937;
border-color: #374151;
}
.info-box h3 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .info-box h3 {
color: #f3f4f6;
}
.info-box p {
color: #6b7280;
margin-bottom: 16px;
}
.dark .info-box p {
color: #9ca3af;
}
.info-box ul {
list-style: none;
padding: 0;
}
.info-box li {
font-size: 0.875rem;
color: #6b7280;
padding: 8px 0;
}
.dark .info-box li {
color: #9ca3af;
}
.cta-bg {
background-color: rgb(20, 113, 76);
color: white;
padding: 60px 20px;
text-align: center;
}
.cta-bg h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 16px;
}
.cta-bg 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) {
.info-grid {
grid-template-columns: 1fr;
}
.service-hero h1 {
font-size: 1.875rem;
}
}
`;
export default function Services() {
const [lang, setLang] = useState<Language>('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 services = [
{ icon: BookOpen, title: t('services.booking'), desc: t('services.bookingDesc'), color: '#3b82f6' },
{ icon: MapPin, title: t('services.seats'), desc: t('services.seatsDesc'), color: '#10b981' },
{ icon: CreditCard, title: t('services.payment'), desc: t('services.paymentDesc'), color: '#a855f7' },
{ icon: Headphones, title: t('services.support'), desc: t('services.supportDesc'), color: '#f97316' },
{ icon: Star, title: t('services.loyalty'), desc: t('services.loyaltyDesc'), color: '#ec4899' },
{ icon: Radio, title: t('services.tracking'), desc: t('services.trackingDesc'), color: '#ef4444' },
];
return (
<>
<style>{styles}</style>
<main>
<section className="service-hero">
<h1>{t('services.title')}</h1>
<p>{t('services.subtitle')}</p>
</section>
<div className="service-grid">
{services.map((service, idx) => {
const Icon = service.icon;
return (
<div key={idx} className="service-card">
<div className="service-icon" style={{ background: `${service.color}20` }}>
<Icon size={28} color={service.color} />
</div>
<h3>{service.title}</h3>
<p>{service.desc}</p>
<Link href="/booking/search">
Learn More <ArrowRight size={16} />
</Link>
</div>
);
})}
</div>
<section className="info-section">
<div className="info-grid">
<div className="info-box">
<h3>Multi-Currency Support</h3>
<p>Book in multiple currencies with real-time exchange rates.</p>
<ul>
<li> ETB (Ethiopian Birr)</li>
<li> DJF (Djiboutian Franc)</li>
<li> USD (US Dollar)</li>
</ul>
</div>
<div className="info-box">
<h3>Age-Based Pricing</h3>
<p>Smart pricing for families with special rates for children.</p>
<ul>
<li> Adults (5 years): Full fare</li>
<li> Children (&lt;5 years): First free</li>
<li> Automatic age calculation</li>
</ul>
</div>
</div>
</section>
<section className="cta-bg">
<h2>Experience the Difference</h2>
<p>Start booking your train journey today and discover our premium services.</p>
<Link href="/booking/search" className="button-white">Book Your Trip Now</Link>
</section>
</main>
</>
);
}

View File

@@ -1,69 +1,172 @@
'use client'; 'use client';
import { Train, User, BookOpen, LogIn } from 'lucide-react'; import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthStore } from '@/lib/auth-store'; import { useEffect, useState } from 'react';
import { useEffect } from 'react'; import { usePathname } from 'next/navigation';
import { LanguageSwitcher } from './LanguageSwitcher';
export default function AppHeader() { export default function AppHeader() {
const { user, isAuthenticated, initialize } = useAuthStore(); const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
useEffect(() => { useEffect(() => {
initialize(); const isDarkMode = document.documentElement.classList.contains('dark');
}, [initialize]); setIsDark(isDarkMode);
return ( }, []);
<header className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo and Brand */}
<Link href="/booking/search" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-600 dark:text-gray-400">
Book train tickets across East Africa
</p>
</div>
</Link>
{/* Right side actions */} const toggleTheme = () => {
<div className="flex items-center gap-2"> const html = document.documentElement;
{!isAuthenticated ? ( const isDarkMode = html.classList.contains('dark');
<Link if (isDarkMode) {
href="/login" html.classList.remove('dark');
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" setIsDark(false);
title="Sign In" localStorage.setItem('theme', 'light');
> } else {
<LogIn className="w-5 h-5" /> html.classList.add('dark');
</Link> setIsDark(true);
) : ( localStorage.setItem('theme', 'dark');
<Link }
href="/profile" };
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Profile" const isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
>
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" /> return (
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline"> <header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
{user?.fullName} <div className="container mx-auto px-4">
</span> <div className="max-w-6xl mx-auto">
</Link> <div className="flex items-center justify-between h-16">
)} {/* Logo */}
<ThemeToggle /> <Link
<Link href="/"
href="/guide" className="flex items-center gap-3 hover:opacity-80 transition-opacity"
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="How to Book"
> >
<BookOpen className="w-5 h-5" /> <div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-100">
Book your train journey with us
</p>
</div>
</Link> </Link>
{/* Desktop Menu - only show for landing pages */}
{isLandingPage && (
<div className="hidden md:flex items-center gap-8">
<Link
href="/"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Home
</Link>
<Link
href="/services"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Services
</Link>
<Link
href="/about"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
About
</Link>
<Link
href="/contact"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Contact
</Link>
</div>
)}
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center justify-center p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Help & FAQ"
>
<HelpCircle className="w-5 h-5" />
</Link>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggler */}
<button
onClick={toggleTheme}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
{/* Mobile Menu Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div> </div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
{isLandingPage && (
<>
<Link
href="/"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Home
</Link>
<Link
href="/services"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Services
</Link>
<Link
href="/about"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
About
</Link>
<Link
href="/contact"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Contact
</Link>
</>
)}
<Link
href="/help"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Help
</Link>
</div>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,165 @@
'use client';
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react';
import Link from 'next/link';
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
import { useEffect, useState } from 'react';
export function Footer() {
const { getLang } = useLanguage();
const [lang, setLang] = useState<Language>('en');
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]);
return (
<footer className="bg-[rgb(20_113_76)] dark:bg-gray-900 text-white py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Company Info */}
<div>
<h4 className="font-semibold mb-2">
Ethio-Djibouti Railway
</h4>
<p className="text-sm text-gray-100 my-3">Connecting East Africa with train travel.
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-100">
<Phone className="w-4 h-4" />
<a href="tel:9546" className="hover:text-gray-200 transition">
9546
</a>
</div>
<div className="flex items-center gap-2 text-gray-100">
<Mail className="w-4 h-4" />
<a href="mailto:edr_@edrsc.com" className="hover:text-gray-200 transition">
edr_@edrsc.com
</a>
</div>
<div className="flex items-center gap-2 text-gray-100">
<MapPin className="w-4 h-4" />
<span>Furi, Sheger City</span>
</div>
</div>
</div>
{/* Quick Links */}
<div>
<h4 className="font-semibold mb-4">{t('nav.home')}</h4>
<ul className="space-y-2 text-sm text-gray-100">
<li>
<Link href="/" className="hover:text-gray-200 transition">
{t('nav.home')}
</Link>
</li>
<li>
<Link href="/services" className="hover:text-gray-200 transition">
{t('nav.services')}
</Link>
</li>
<li>
<Link href="/about" className="hover:text-gray-200 transition">
{t('nav.about')}
</Link>
</li>
<li>
<Link href="/contact" className="hover:text-gray-200 transition">
{t('nav.contact')}
</Link>
</li>
</ul>
</div>
{/* Support */}
<div>
<h4 className="font-semibold mb-4">{t('footer.support')}</h4>
<ul className="space-y-2 text-sm text-gray-100">
<li>
<Link href="/help" className="hover:text-gray-200 transition">
{t('nav.help')}
</Link>
</li>
<li>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.privacy')}
</a>
</li>
<li>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.terms')}
</a>
</li>
</ul>
</div>
{/* Social Media */}
<div>
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
<div className="flex gap-3">
<a
href="https://web.facebook.com/ethiodjiboutirailwaysc"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Facebook"
>
<Facebook className="w-5 h-5" />
</a>
<a
href="https://twitter.com/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Twitter"
>
<Twitter className="w-5 h-5" />
</a>
<a
href="https://instagram.com/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Instagram"
>
<Instagram className="w-5 h-5" />
</a>
<a
href="https://linkedin.com/company/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5" />
</a>
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-white border-opacity-20 dark:border-gray-700 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center text-sm text-gray-100">
<p>
&copy; 2024 Ethio-Djibouti Railway. {t('footer.rights')}
</p>
<div className="flex gap-6 mt-4 md:mt-0">
<a href="#" className="hover:text-gray-200 transition">
{t('footer.privacy')}
</a>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.terms')}
</a>
</div>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { LanguageSwitcher } from './LanguageSwitcher';
import { getTranslation, Language } from '@/lib/i18n';
import { useLanguage } from '@/lib/i18n';
export function LandingNav() {
const [isOpen, setIsOpen] = useState(false);
const [lang, setLang] = useState<Language>('en');
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
const { getLang } = useLanguage();
const t = (key: string) => getTranslation(lang, key);
useEffect(() => {
setMounted(true);
setLang(getLang());
const handleLanguageChange = (e: any) => setLang(e.detail);
window.addEventListener('languageChange', handleLanguageChange);
return () => window.removeEventListener('languageChange', handleLanguageChange);
}, [getLang]);
useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark');
setIsDark(isDarkMode);
}, []);
const toggleTheme = () => {
const html = document.documentElement;
const isDarkMode = html.classList.contains('dark');
if (isDarkMode) {
html.classList.remove('dark');
setIsDark(false);
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
setIsDark(true);
localStorage.setItem('theme', 'dark');
}
};
const menuItems = [
{ label: t('nav.home'), href: '/' },
{ label: t('nav.services'), href: '/services' },
{ label: t('nav.about'), href: '/about' },
{ label: t('nav.contact'), href: '/contact' },
];
if (!mounted) return null;
return (
<nav className="sticky top-0 z-40 bg-[rgb(20_113_76)] border-b border-[rgb(16_89_60)] shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-100">
Book your train journey with us
</p>
</div>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
{item.label}
</Link>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
<LanguageSwitcher />
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center gap-1.5 px-3 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Help & FAQ"
>
<HelpCircle className="w-5 h-5" />
</Link>
{/* Theme Toggler */}
<button
onClick={toggleTheme}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
<Link
href="/booking/search"
className="hidden sm:block px-4 py-2 bg-white text-[rgb(20_113_76)] text-sm font-medium rounded-lg transition-all duration-200 transform hover:scale-105 hover:bg-gray-100"
>
{t('nav.bookNow')}
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="md:hidden border-t border-white border-opacity-20 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{item.label}
</Link>
))}
<Link
href="/help"
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{t('nav.help')}
</Link>
<Link
href="/booking/search"
className="block px-4 py-2 bg-white text-[rgb(20_113_76)] font-medium rounded-lg transition-colors hover:bg-gray-100"
onClick={() => setIsOpen(false)}
>
{t('nav.bookNow')}
</Link>
</div>
)}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useEffect, useState } from 'react';
import { useLanguage, Language } from '@/lib/i18n';
import { Globe } from 'lucide-react';
const languages: { code: Language; label: string }[] = [
{ code: 'en', label: 'English' },
{ code: 'am', label: 'አማርኛ' },
{ code: 'om', label: 'Afan Oromo' },
{ code: 'fr', label: 'Français' },
];
export function LanguageSwitcher() {
const [isOpen, setIsOpen] = useState(false);
const [currentLang, setCurrentLang] = useState<Language>('en');
const { getLang, setLang } = useLanguage();
useEffect(() => {
setCurrentLang(getLang());
}, [getLang]);
const handleLanguageChange = (lang: Language) => {
setLang(lang);
setCurrentLang(lang);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Change language"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-40 animate-in fade-in zoom-in-95 duration-200">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`w-full text-left px-4 py-2.5 transition-all duration-200 ${
currentLang === lang.code
? 'bg-primary text-white font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{lang.label}
</button>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { useEffect, useState } from 'react';
export function LoadingIndicator() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleStart = () => setIsVisible(true);
const handleEnd = () => setIsVisible(false);
window.addEventListener('beforeunload', handleStart);
window.addEventListener('load', handleEnd);
return () => {
window.removeEventListener('beforeunload', handleStart);
window.removeEventListener('load', handleEnd);
};
}, []);
if (!isVisible) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[9999]">
<div className="h-1 bg-gradient-to-r from-primary via-primary/70 to-primary animate-pulse">
<div className="h-full bg-gradient-to-r from-primary to-primary/50 animate-[shimmer_2s_infinite]" />
</div>
</div>
);
}

View File

@@ -15,7 +15,7 @@ const steps: Step[] = [
{ id: 'seats', name: 'Seats', href: '/booking/seats' }, { id: 'seats', name: 'Seats', href: '/booking/seats' },
{ id: 'review', name: 'Review', href: '/booking/review' }, { id: 'review', name: 'Review', href: '/booking/review' },
{ id: 'payment', name: 'Payment', href: '/booking/payment' }, { id: 'payment', name: 'Payment', href: '/booking/payment' },
{ id: 'confirmation', name: 'Done', href: '/booking/confirmation' }, { id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' },
]; ];
interface ProgressIndicatorProps { interface ProgressIndicatorProps {
@@ -27,7 +27,7 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
return ( return (
<nav aria-label="Progress" className="py-6"> <nav aria-label="Progress" className="py-6">
<ol className="flex items-center max-w-4xl mx-auto"> <ol className="flex items-center max-w-6xl mx-auto">
{steps.map((step, index) => { {steps.map((step, index) => {
const isComplete = index < currentIndex; const isComplete = index < currentIndex;
const isCurrent = index === currentIndex; const isCurrent = index === currentIndex;

View File

@@ -0,0 +1,279 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import { MapPin, Users, Search, Plus, Minus, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
originStationId: z.string().min(1),
destinationStationId: z.string().min(1),
departureDate: z.string().min(1),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
}).refine((data) => data.originStationId !== data.destinationStationId, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
});
type SearchForm = z.infer<typeof searchSchema>;
interface SearchWidgetProps {
fullWidth?: boolean;
onSearch?: () => void;
}
export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {
const router = useRouter();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const { data: stations, isLoading } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema),
defaultValues: {
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',
departureDate: new Date().toISOString().split('T')[0],
},
});
const originId = watch('originStationId');
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const onSubmit = (data: SearchForm) => {
setSearchCriteria({
...data,
adultCount: data.adultCount,
childCount: data.childCount,
nationality: data.nationality,
});
const params = new URLSearchParams({
origin: data.originStationId,
destination: data.destinationStationId,
date: data.departureDate,
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
});
if (onSearch) onSearch();
router.push(`/booking/results?${params}`);
};
return (
<div className={fullWidth ? 'w-full' : 'w-full max-w-6xl mx-auto'}>
<form onSubmit={handleSubmit(onSubmit)} className="bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-lg border border-gray-200/20 dark:border-gray-700/20 overflow-visible backdrop-blur-sm">
<div className="p-6 md:p-8 overflow-visible">
{/* Row 1: From, To, Date */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* From */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">From</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('originStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select departure</option>
{stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
{errors.originStationId && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.originStationId.message}</p>
)}
</div>
{/* To */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">To</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('destinationStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select arrival</option>
{stations?.map((s) => (
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
))}
</select>
</div>
{errors.destinationStationId && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
)}
</div>
{/* Date */}
<div className="space-y-2 relative z-30 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
<ModernDatePicker
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
onChange={(date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
setValue('departureDate', `${year}-${month}-${day}`);
}}
minDate={new Date()}
placeholder="Select date"
/>
{errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)}
</div>
</div>
{/* Row 2: Passengers, Nationality, Promo Code */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* Passengers Dropdown */}
<div className="space-y-2 relative z-20">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Passengers</label>
<button
type="button"
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 flex items-center justify-between hover:border-primary transition-colors"
>
<span className="flex items-center gap-2">
<Users className="w-4 h-4 text-primary" />
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
</span>
<ChevronDown className={`w-4 h-4 transition-transform text-primary ${isPassengerOpen ? 'rotate-180' : ''}`} />
</button>
{/* Passenger Dropdown Menu */}
{isPassengerOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg z-50 p-4 space-y-4">
{/* Adults */}
<div>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current > 1) setValue('adultCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) <= 1}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
{/* Children */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Nationality */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Nationality</label>
<select
{...register('nationality')}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="ETHIOPIAN">Ethiopian</option>
<option value="DJIBOUTIAN">Djiboutian</option>
<option value="OTHER">Other</option>
</select>
</div>
{/* Promo Code */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
<input
type="text"
placeholder="Enter promo code"
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
</div>
</div>
{/* Row 3: Search Button */}
<div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl"
>
<Search className="w-5 h-5 text-white" />
<span>Search Train</span>
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -7,11 +7,22 @@ interface User {
fullName: string; fullName: string;
phone?: string; phone?: string;
role: string; role: string;
passengerId?: string;
dateOfBirth?: string; dateOfBirth?: string;
gender?: string; gender?: string;
nationality?: string; nationality?: string;
nationalityCode?: string;
nationalId?: string;
passportNumber?: string;
passportCountry?: string;
passportIssueDate?: string;
passportExpiryDate?: string;
passportIssuingAuthority?: string;
faydaVerified?: boolean; faydaVerified?: boolean;
faydaSub?: string; faydaSub?: string;
faydaVerifiedAt?: string;
lastLoginAt?: string;
createdAt?: string;
} }
interface AuthState { interface AuthState {

View File

@@ -25,6 +25,7 @@ export interface PassengerDetail {
idDocumentType?: string; idDocumentType?: string;
isPrimaryPassenger: boolean; isPrimaryPassenger: boolean;
seatId?: string; seatId?: string;
seatNumber?: string;
phone?: string; phone?: string;
email?: string; email?: string;
gender?: string; gender?: string;
@@ -58,6 +59,7 @@ interface BookingState {
pnr: string | null; pnr: string | null;
selectedPaymentMethod: string | null; selectedPaymentMethod: string | null;
createAccount: boolean; createAccount: boolean;
passengerId: string | null;
setSearchCriteria: (criteria: SearchCriteria) => void; setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void; setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -67,11 +69,12 @@ interface BookingState {
setPNR: (pnr: string) => void; setPNR: (pnr: string) => void;
setPaymentMethod: (method: string) => void; setPaymentMethod: (method: string) => void;
setCreateAccount: (create: boolean) => void; setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
clearBooking: () => void; clearBooking: () => void;
} }
export const useBookingStore = create<BookingState>()(persist( export const useBookingStore = create<BookingState>()(persist(
(set) => ({ (set) => (({
searchCriteria: null, searchCriteria: null,
selectedSchedule: null, selectedSchedule: null,
passengers: [], passengers: [],
@@ -80,6 +83,7 @@ export const useBookingStore = create<BookingState>()(persist(
pnr: null, pnr: null,
selectedPaymentMethod: null, selectedPaymentMethod: null,
createAccount: false, createAccount: false,
passengerId: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }), setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }), setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
@@ -89,6 +93,7 @@ export const useBookingStore = create<BookingState>()(persist(
setPNR: (pnr) => set({ pnr }), setPNR: (pnr) => set({ pnr }),
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }), setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }), setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }),
clearBooking: () => set({ clearBooking: () => set({
searchCriteria: null, searchCriteria: null,
selectedSchedule: null, selectedSchedule: null,
@@ -98,8 +103,9 @@ export const useBookingStore = create<BookingState>()(persist(
pnr: null, pnr: null,
selectedPaymentMethod: null, selectedPaymentMethod: null,
createAccount: false, createAccount: false,
passengerId: null,
}), }),
}), } as BookingState)),
{ {
name: 'booking-storage', name: 'booking-storage',
storage: createJSONStorage(() => { storage: createJSONStorage(() => {

View File

@@ -0,0 +1,419 @@
export type Language = 'en' | 'am' | 'om' | 'fr';
const translations = {
en: {
nav: {
home: 'Home',
services: 'Services',
about: 'About',
contact: 'Contact',
help: 'Help',
bookNow: 'Book now',
},
home: {
hero: 'Travel across the East Africa',
heroSub: 'Experience seamless train travel from Ethiopia to Djibouti',
cta: 'Start booking',
searchNow: 'Search trips',
features: 'Why choose EDR?',
comfortable: 'Comfortable journey',
comfortDesc: 'Modern coaches with seating and sleeping berths',
affordable: 'Affordable pricing',
affordableDesc: 'Competitive fares with discounts for families',
safe: 'Safe and reliable',
safeDesc: 'On-time arrivals with 24/7 customer support',
fast: 'Quick booking',
fastDesc: 'Book in minutes, pay with multiple methods',
},
services: {
title: 'Our services',
subtitle: 'Everything you need for a great journey',
booking: 'Easy booking',
bookingDesc: 'Simple online booking with instant confirmation',
seats: 'Seat selection',
seatsDesc: 'Choose from economy, standard, and VIP coaches',
payment: 'Flexible payment',
paymentDesc: 'Pay with Telebirr, CBE Birr, cards, or wallet',
support: 'Customer support',
supportDesc: 'Live chat and 24/7 assistance available',
loyalty: 'Loyalty rewards',
loyaltyDesc: 'Earn points and unlock exclusive benefits',
tracking: 'Live tracking',
trackingDesc: 'Real-time updates on your train journey',
},
about: {
title: 'About EDR',
subtitle: 'Connecting East Africa',
mission: 'Our mission',
missionText: 'To provide reliable, affordable, and comfortable train travel connecting Ethiopia and Djibouti.',
network: 'Extensive network',
networkText: '21 stations across Ethiopia and Djibouti with modern infrastructure.',
comfort: 'Comfort first',
comfortText: 'Modern coaches designed for your comfort with multiple classes.',
eco: 'Eco-friendly',
ecoText: 'Sustainable travel option that reduces carbon footprint.',
},
contact: {
title: 'Get in touch',
subtitle: 'We are here to help',
email: 'Email',
phone: 'Phone',
address: 'Address',
form: 'Send us a message',
name: 'Full Name',
emailField: 'Email address',
subject: 'Subject',
message: 'Message',
send: 'Send message',
sending: 'Sending...',
success: 'Message sent successfully!',
error: 'Failed to send message',
},
help: {
title: 'Help and FAQs',
subtitle: 'Find answers to common questions',
bookingFaq: 'Booking questions',
how: 'How do I book a ticket?',
howAnswer: 'Visit our booking page, search for available trips, select your seats, and complete payment.',
modify: 'Can I modify my booking?',
modifyAnswer: 'Yes, you can modify bookings up to 24 hours before departure from your profile.',
cancel: 'What is the cancellation policy?',
cancelAnswer: 'Cancellations made 48 hours before departure receive full refund.',
paymentFaq: 'Payment questions',
payMethods: 'What payment methods do you accept?',
payMethodsAnswer: 'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and wallet balance.',
refund: 'When will I receive my refund?',
refundAnswer: 'Refunds are processed within 5-7 business days.',
other: 'Other questions',
docs: 'What documents do I need?',
docsAnswer: 'Valid national ID, passport, or travel permit required.',
help: 'Still need help?',
contact: 'Contact our support team',
},
footer: {
about: 'About',
support: 'Support',
privacy: 'Privacy policy',
terms: 'Terms of service',
follow: 'Follow us',
rights: 'All rights reserved.',
},
},
am: {
nav: {
home: 'ቤት',
services: 'አገልግሎቶች',
about: 'ስለ ኛ',
contact: 'አግኙን',
help: 'ርዳታ',
bookNow: 'አሁን ይመዝገቡ',
},
home: {
hero: 'በምስራቅ አፍሪካ ውስጥ ይጓዙ',
heroSub: 'ከኢትዮጵያ ወደ ጂቡቲ ello seamless train travel ያስተዋውቁ',
cta: 'ቁጠባ ይጀምሩ',
searchNow: 'ጉብኝቶች ይፈልጉ',
features: 'ለምን ኢደር ይምረጡ?',
comfortable: 'ምቹ ጉዞ',
comfortDesc: 'ዘመናዊ ሕንፃዎች ወንበር እና ማተም ወራጆች ጋር',
affordable: 'ርካሽ ዋጋ',
affordableDesc: 'ለቤተሰቦች ምጣኔ ሞገድ እና ቅናሾች',
safe: 'ደህንነተኛ & አስተማማኝ',
safeDesc: 'በጊዜ ምጡና 24/7 ደንበኛ ድጋፍ',
fast: 'ፈጣን ቁጠባ',
fastDesc: 'በደቂቃዎች ይመዝገቡ፣ ብዙ ዘዴዎችን ይከፍሉ',
},
services: {
title: 'አገልግሎቶቻችን',
subtitle: 'ታላቅ ጉዞ ለሚፈልጉ ሁሉ ነገር',
booking: 'ቀላል ቁጠባ',
bookingDesc: 'ቀላል የመስመር ላይ ቁጠባ ወቅታዊ ማረጋገጫ ጋር',
seats: 'ወንበር ምርጫ',
seatsDesc: 'ኢኮኖሚ፣ መደበኛ እና VIP ሕንፃዎች ምረጡ',
payment: '유연한ከጊዜ ወደ ጊዜ ክፍያ',
paymentDesc: 'Telebirr፣ CBE Birr፣ ካርዶች ወይም ዋሌት ይከፍሉ',
support: 'ደንበኛ ድጋፍ',
supportDesc: 'ライブ ቻት እና 24/7 ረዳት ይገኛሉ',
loyalty: 'ታማኝነት ሽልማቶች',
loyaltyDesc: 'ነጥቦች ያዙ እና ብቁ ጥቅሞች ያስከፍቱ',
tracking: 'ライブ ትንተና',
trackingDesc: 'የእርስዎ ባቡር ጉዞ ውስጥ ወቅታዊ ማሻሻያዎች',
},
about: {
title: 'ስለ ኢትዮጵያ-ጂቡቲ ባቡር',
subtitle: 'ምስራቅ አፍሪካ ያገናኙ',
mission: 'ራሳችን መጠን',
missionText: 'ኢትዮጵያ እና ጂቡቲን የሚያገናኙ አስተማማኝ፣ ርካሽ እና ምቹ ባቡር ጉዞ ይሰጡ።',
network: 'ሰፊ አውታር',
networkText: 'ኢትዮጵያ እና ጂቡቲ ውስጥ 21 ጣቢያዎች ዘመናዊ መሰረተ ልማት ጋር።',
comfort: 'ምቹ ሞላላ',
comfortText: 'ብዙ ክፍሎች ጋር የእርስዎ comfort ለ ዲዛይን ዘመናዊ ሕንፃዎች።',
eco: 'ପରିବେश __ 친화적',
ecoText: 'ካርቦን እግድ ሪሞት የሚቀንስ sustainable ጉዞ አማራጭ።',
},
contact: {
title: 'ከኛ ጋር ግንኙነት ወስጥ ደርሱ',
subtitle: 'እኛ ረድ ለ ልንሆን ሞገዱ',
email: 'ኢሜይል',
phone: 'ስልክ',
address: 'አድራሻ',
form: 'ለኛ መልእክት ይላኩ',
name: 'ሙሉ ስም',
emailField: 'ኢሜይል አድራሻ',
subject: 'ርዕስ',
message: 'መልእክት',
send: 'መልእክት ይላኩ',
sending: 'በመላክ ላይ...',
success: 'መልእክት በተሳካ ተልኩ!',
error: 'መልእክት ወደ ላክ ያልተሳካ',
},
help: {
title: 'ርዳታ & FAQs',
subtitle: 'ከ general ጥያቄዎች የሚመለስ መልስ ይፈልጉ',
bookingFaq: 'ቁጠባ ጥያቄዎች',
how: 'ስንት በየት ላይ ቁጠባ?',
howAnswer: 'ቁጠባ ገጽ ይጎብኙ፣ ዞሯ ለ ይፈልጉ ፣ ወንበር ይምረጡ ዝሕ ክፍያ።',
modify: 'እኔ ቁጠባ እንደገና ማስተካከል ይችላሉ?',
modifyAnswer: 'አዎ፣ መውጫ 24 ሰዓታት በፊት ቁጠባ እንደገና ማስተካከል ይችላሉ።',
cancel: 'cancellation ወሳኔ ምንድ ነው?',
cancelAnswer: 'አስመልከት 48 ሰዓታት በፊት አውጪ ሙሉ መልስ ያገኛሉ።',
paymentFaq: 'ክፍያ ጥያቄዎች',
payMethods: 'Telebirr, CBE Birr, eBirr, ክレジット/ዲቢት ካርዶች, እና ዋሌት ሚዛን ኤ acceptedይገቡ।',
payMethodsAnswer: 'Telebirr, CBE Birr, eBirr, credit/debit ካርዶች፣ እና ዋሌት ሚዛን ยอมrับ',
refund: 'my አሌ ሥራ refund?',
refundAnswer: 'Refund በ 5-7 ሥራ ቀናት ውስጥ ተወስኖልት።',
other: 'ሌሎች ጥያቄዎች',
docs: 'ኔ ወሰደ ምን documentation?',
docsAnswer: 'ዋጋ ብሔር ID፣ ፓስፖርት ወይም ጉዞ permit አስፈላጊ።',
help: 'አሁንም ረዳታ ፈልገው?',
contact: 'ደንበኛ ድጋፍ ቡድን ጋር ማንቲ',
},
footer: {
about: 'ስለ',
contact: 'አግኙን',
privacy: 'ግላዊነት ፖሊሲ',
terms: 'service ውል',
follow: 'ከሱ ተከተሉ',
rights: 'ሁሉ ሙሉ።',
},
},
om: {
nav: {
home: 'Mana',
services: 'Tajaajilaa',
about: 'Waa',
contact: 'Nu Ilaali',
help: 'Gargaarsa',
bookNow: 'Amma Qindeessuu',
},
home: {
hero: 'Kaasaa Baafata Gidiraa',
heroSub: 'Kunoosni caraa qammateen Itoophiyaa hoo Jibuuraa jira.',
cta: 'Qindeessuu Jalqabi',
searchNow: 'Naannoo Barbaadi',
features: 'Maaliif EDR Filachuu?',
comfortable: 'Wantaa Mijataa',
comfortDesc: 'Haalaan biraa konkolaata mooniisa seeda jira.',
affordable: 'Gatii Gabbina',
affordableDesc: 'Karoora muummichaa jala maallaqa qabeenya.',
safe: 'Nageenya & Jidha',
safeDesc: 'Yeroo jilbaa dhumaasuu fi gargaarsa 24/7 deggara.',
fast: 'Qindeessuu Abadii',
fastDesc: 'Yeroo giddu gidduu qindeessuu, mallaatoo ijaa gadii.',
},
services: {
title: 'Tajaajilaa Keenya',
subtitle: 'Waa waliigalaan wantoota gid caraa mijataa',
booking: 'Qindeessuu Salphaa',
bookingDesc: 'Qindeessuu interneetii salphaa jidha gaafatama',
seats: 'Filannoo Teessaa',
seatsDesc: 'Ekonomii, idileessaa jala VIP konkolaata filachuu',
payment: 'Maallaqa Giddu Gidduu',
paymentDesc: 'Telebirr, CBE Birr, kaardii ykn wallaattii jalqabi',
support: 'Gargaarsa Fayyadhaa',
supportDesc: 'Haftuu liixii jala gargaarsa 24/7 jira',
loyalty: 'Gammachiisa Jidha',
loyaltyDesc: 'Qooda qabu jila waan gaarii gargaara',
tracking: 'Jidha Liixii',
trackingDesc: 'Jiraataa kunoosni caraa qammateen gammachisu',
},
about: {
title: 'Waa Baafata Itoophiyaa-Jibuuraa',
subtitle: 'Baafata Gidiraa Yoo Wal Qabu',
mission: 'Kora Keenya',
missionText: 'Nageenya jidha, gatii gabbina, jidha mijataa Itoophiyaa jala Jibuuraa tajaajili.',
network: 'Shabdalee Babaasaa',
networkText: '21 taasaa Itoophiyaa jala Jibuuraa keessatti konkolaata biraa.',
comfort: 'Mirga Jalqabaa',
comfortText: 'Konkolaata mooniisa mijataa kee waraqsa gidduu jira.',
eco: 'Kaasaalee Mijataa',
ecoText: 'Caraa ijaa yoo sadarka karbuuni muraa gidduu.',
},
contact: {
title: 'Nu Ilaali',
subtitle: 'Nu gargaaruufi barbaachisaa',
email: 'Imeelii',
phone: 'Bilbila',
address: 'Tuulaa',
form: 'Naaraan Itti Ergi',
name: 'Maqaa Guutuu',
emailField: 'Imeelii tuula',
subject: 'Mata',
message: 'Itti Ergi',
send: 'Naaraan Ergi',
sending: 'Erguun jira...',
success: 'Naaraan gaafatama jira!',
error: 'Naaraan erguu hin raasuu',
},
help: {
title: 'Gargaarsa & FAQs',
subtitle: 'Gaaffii guutuu jala deebii barbaadi',
bookingFaq: 'Gaaffii Qindeessuu',
how: 'Akkamitti qindeessuu?',
howAnswer: 'Fuula qindeessuu dhugi, naannoo barbaadi, teessaa filachuu, maallaqa xumura.',
modify: 'Qindeessuu koo maratti jidha adeemsa?',
modifyAnswer: 'Eenyee, walta 24 sa itti fufu qindeessuu maratti jidha adeemsa dandeenya.',
cancel: 'Hoggansa gaafatama maal jira?',
cancelAnswer: 'Walta 48 sa itti fufu walitti erga dhumaasuu guutuu arguu jidha.',
paymentFaq: 'Gaaffii Maallaqa',
payMethods: 'Maallaqa keessaa maallaqa hayyamaa?',
payMethodsAnswer: 'Telebirr, CBE Birr, eBirr, kaardii kreediitii/dibati, jala wallaattii haa jira.',
refund: 'Yeroo maallaqa deebina?',
refundAnswer: 'Maallaaq 5-7 guyyaa hojii keessatti deebi.',
other: 'Gaaffii Biraa',
docs: 'Waraqaa maal barbaachisa?',
docsAnswer: 'Kaardii ID bakka, paaspoortii ykn walii haa barbaachisaa.',
help: 'Haaluma facaasin gargaarsa Barbaadi?',
contact: 'Gargaarsa fayyadhaa mana waliin ilaali',
},
footer: {
about: 'Waa',
contact: 'Nu Ilaali',
privacy: 'Kabaja Kunnamuu',
terms: 'Seera Tajaajila',
follow: 'Isaa Hordofu',
rights: 'Hundi moodeewwan.',
},
},
fr: {
nav: {
home: 'Accueil',
services: 'Services',
about: 'À Propos',
contact: 'Contact',
help: 'Aide',
bookNow: 'Réserver Maintenant',
},
home: {
hero: 'Voyagez à travers l\'Afrique de l\'Est',
heroSub: 'Découvrez les voyages en train sans interruption d\'Éthiopie à Djibouti',
cta: 'Commencer la Réservation',
searchNow: 'Rechercher des Trajets',
features: 'Pourquoi Choisir EDR?',
comfortable: 'Voyage Confortable',
comfortDesc: 'Autobus modernes avec sièges et couchettes',
affordable: 'Prix Abordables',
affordableDesc: 'Tarifs compétitifs avec réductions pour familles',
safe: 'Sûr & Fiable',
safeDesc: 'Arrivées à l\'heure avec support client 24/7',
fast: 'Réservation Rapide',
fastDesc: 'Réservez en minutes, payez par plusieurs méthodes',
},
services: {
title: 'Nos Services',
subtitle: 'Tout ce dont vous avez besoin pour un excellent voyage',
booking: 'Réservation Facile',
bookingDesc: 'Réservation en ligne simple avec confirmation instantanée',
seats: 'Sélection des Sièges',
seatsDesc: 'Choisissez parmi les autobus économique, standard et VIP',
payment: 'Paiement Flexible',
paymentDesc: 'Payez avec Telebirr, CBE Birr, cartes ou portefeuille',
support: 'Support Client',
supportDesc: 'Chat en direct et assistance 24/7 disponibles',
loyalty: 'Récompenses de Fidélité',
loyaltyDesc: 'Gagnez des points et déverrouillez des avantages exclusifs',
tracking: 'Suivi en Direct',
trackingDesc: 'Mises à jour en temps réel de votre voyage en train',
},
about: {
title: 'À Propos du Chemin de Fer Éthiopie-Djibouti',
subtitle: 'Connecter l\'Afrique de l\'Est',
mission: 'Notre Mission',
missionText: 'Offrir des voyages en train fiables, abordables et confortables reliant l\'Éthiopie et Djibouti.',
network: 'Réseau Étendu',
networkText: '21 gares à travers l\'Éthiopie et Djibouti avec infrastructure moderne.',
comfort: 'Confort D\'Abord',
comfortText: 'Autobus modernes conçus pour votre confort avec plusieurs catégories.',
eco: 'Écologique',
ecoText: 'Option de voyage durable qui réduit l\'empreinte carbone.',
},
contact: {
title: 'Contactez-Nous',
subtitle: 'Nous sommes là pour vous aider',
email: 'Email',
phone: 'Téléphone',
address: 'Adresse',
form: 'Envoyez-Nous un Message',
name: 'Nom Complet',
emailField: 'Adresse E-mail',
subject: 'Sujet',
message: 'Message',
send: 'Envoyer le Message',
sending: 'Envoi en cours...',
success: 'Message envoyé avec succès!',
error: 'Échec de l\'envoi du message',
},
help: {
title: 'Aide & Questions Fréquentes',
subtitle: 'Trouvez des réponses aux questions courantes',
bookingFaq: 'Questions de Réservation',
how: 'Comment réserver un billet?',
howAnswer: 'Visitez notre page de réservation, recherchez les trajets disponibles, sélectionnez vos sièges et complétez le paiement.',
modify: 'Puis-je modifier ma réservation?',
modifyAnswer: 'Oui, vous pouvez modifier les réservations jusqu\'à 24 heures avant le départ.',
cancel: 'Quelle est la politique d\'annulation?',
cancelAnswer: 'Les annulations effectuées 48 heures avant le départ reçoivent un remboursement complet.',
paymentFaq: 'Questions de Paiement',
payMethods: 'Quels modes de paiement acceptez-vous?',
payMethodsAnswer: 'Nous acceptons Telebirr, CBE Birr, eBirr, cartes bancaires et portefeuille.',
refund: 'Quand recevrai-je mon remboursement?',
refundAnswer: 'Les remboursements sont traités dans les 5-7 jours ouvrables.',
other: 'Autres Questions',
docs: 'Quels documents dois-je avoir?',
docsAnswer: 'Une carte d\'identité nationale, un passeport ou un permis de voyage valide est requis.',
help: 'Avez-vous toujours besoin d\'aide?',
contact: 'Contactez notre équipe d\'assistance',
},
footer: {
about: 'À Propos',
contact: 'Contact',
privacy: 'Politique de Confidentialité',
terms: 'Conditions d\'Utilisation',
follow: 'Suivez-Nous',
rights: 'Tous droits réservés.',
},
},
};
export function getTranslation(lang: Language, key: string): string {
const keys = key.split('.');
let value: any = translations[lang];
for (const k of keys) {
value = value?.[k];
}
return value || key;
}
export function useLanguage() {
const getLang = (): Language => {
if (typeof window === 'undefined') return 'en';
return (localStorage.getItem('language') as Language) || 'en';
};
const setLang = (lang: Language) => {
localStorage.setItem('language', lang);
window.dispatchEvent(new CustomEvent('languageChange', { detail: lang }));
};
return { getLang, setLang };
}

View File

@@ -1,6 +1,4 @@
import { format, toZonedTime } from 'date-fns-tz'; import { format } from 'date-fns';
const ADDIS_TZ = 'Africa/Addis_Ababa';
export const formatCurrency = (amount: number, currency: string = 'ETB'): string => { export const formatCurrency = (amount: number, currency: string = 'ETB'): string => {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
@@ -11,16 +9,13 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
}; };
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => {
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); return format(new Date(date), formatStr);
return format(zonedDate, formatStr, { timeZone: ADDIS_TZ });
}; };
export const formatDateTime = (date: string | Date): string => { export const formatDateTime = (date: string | Date): string => {
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); return format(new Date(date), 'MMM dd, yyyy HH:mm');
return format(zonedDate, 'MMM dd, yyyy HH:mm', { timeZone: ADDIS_TZ });
}; };
export const formatTime = (date: string | Date): string => { export const formatTime = (date: string | Date): string => {
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); return format(new Date(date), 'HH:mm');
return format(zonedDate, 'HH:mm', { timeZone: ADDIS_TZ });
}; };

View File

@@ -1,61 +1,88 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class', darkMode: 'class',
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { primary: 'rgb(20 113 76)',
DEFAULT: 'rgb(20, 113, 76)',
50: '#f0f9f5',
100: '#d9f0e6',
200: '#b6e1cf',
300: '#87ccb0',
400: '#56b08d',
500: '#14714c',
600: '#115d3f',
700: '#0f4a33',
800: '#0d3b29',
900: '#0b3122',
},
}, },
fontFamily: { backgroundColor: {
sans: [ primary: 'rgb(20 113 76)',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
],
}, },
boxShadow: { textColor: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)', primary: 'rgb(20 113 76)',
},
borderColor: {
primary: 'rgb(20 113 76)',
},
ringColor: {
primary: 'rgb(20 113 76)',
}, },
animation: { animation: {
'fade-in': 'fadeIn 0.5s ease-in-out', 'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)',
'slide-up': 'slideUp 0.4s ease-out', 'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s infinite',
'slide-in-left': 'slide-in-left 0.5s ease-out',
'slide-in-right': 'slide-in-right 0.5s ease-out',
}, },
keyframes: { keyframes: {
fadeIn: { 'bounce-in': {
'0%': { opacity: '0' }, '0%': {
'100%': { opacity: '1' }, opacity: '0',
transform: 'translateY(20px) scale(0.9)',
},
'50%': {
opacity: '1',
},
'100%': {
opacity: '1',
transform: 'translateY(0) scale(1)',
},
}, },
slideUp: { 'float': {
'0%': { transform: 'translateY(10px)', opacity: '0' }, '0%, 100%': {
'100%': { transform: 'translateY(0)', opacity: '1' }, transform: 'translateY(0px)',
},
'50%': {
transform: 'translateY(-20px)',
},
},
'shimmer': {
'0%': {
'background-position': '-1000px 0',
},
'100%': {
'background-position': '1000px 0',
},
},
'slide-in-left': {
'from': {
opacity: '0',
transform: 'translateX(-20px)',
},
'to': {
opacity: '1',
transform: 'translateX(0)',
},
},
'slide-in-right': {
'from': {
opacity: '0',
transform: 'translateX(20px)',
},
'to': {
opacity: '1',
transform: 'translateX(0)',
},
}, },
}, },
}, },
}, },
plugins: [], plugins: [],
darkMode: 'class',
}; };

293
pnpm-lock.yaml generated
View File

@@ -153,9 +153,6 @@ importers:
'@nestjs/jwt': '@nestjs/jwt':
specifier: ^10.2.0 specifier: ^10.2.0
version: 10.2.0(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 10.2.0(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.1.19 specifier: ^11.1.19
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23) version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)
@@ -201,15 +198,12 @@ importers:
dotenv: dotenv:
specifier: ^17.4.2 specifier: ^17.4.2
version: 17.4.2 version: 17.4.2
express:
specifier: ^4.18.2
version: 4.22.2
jose: jose:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
passport:
specifier: ^0.7.0
version: 0.7.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
pg: pg:
specifier: ^8.21.0 specifier: ^8.21.0
version: 8.21.0 version: 8.21.0
@@ -224,7 +218,7 @@ importers:
version: 7.8.2 version: 7.8.2
swagger-ui-express: swagger-ui-express:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.1(express@5.2.1) version: 5.0.1(express@4.22.2)
tsconfig-paths: tsconfig-paths:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@@ -250,15 +244,15 @@ importers:
'@types/bcrypt': '@types/bcrypt':
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
'@types/express':
specifier: ^4.17.21
version: 4.17.25
'@types/jest': '@types/jest':
specifier: ^29.5.11 specifier: ^29.5.11
version: 29.5.14 version: 29.5.14
'@types/node': '@types/node':
specifier: ^20.10.6 specifier: ^20.10.6
version: 20.19.41 version: 20.19.41
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
'@types/qrcode': '@types/qrcode':
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.6 version: 1.5.6
@@ -1765,9 +1759,15 @@ packages:
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/express-serve-static-core@4.19.8':
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==}
'@types/express-serve-static-core@5.1.1': '@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
'@types/express@5.0.6': '@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
@@ -1795,9 +1795,6 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/jsonwebtoken@9.0.5': '@types/jsonwebtoken@9.0.5':
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
@@ -1807,8 +1804,8 @@ packages:
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/ms@2.1.0': '@types/mime@1.3.5':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/node@14.18.63': '@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
@@ -1819,15 +1816,6 @@ packages:
'@types/node@25.9.1': '@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
'@types/passport-strategy@0.2.38':
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -1848,9 +1836,15 @@ packages:
'@types/react@18.3.29': '@types/react@18.3.29':
resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==}
'@types/send@0.17.6':
resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==}
'@types/send@1.2.1': '@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@1.15.10':
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
@@ -2115,6 +2109,10 @@ packages:
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
accepts@2.0.0: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -2417,6 +2415,9 @@ packages:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
array-ify@1.0.0: array-ify@1.0.0:
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
@@ -2583,6 +2584,10 @@ packages:
bluebird@3.4.7: bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
body-parser@1.20.5:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2886,6 +2891,10 @@ packages:
console-control-strings@1.1.0: console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-disposition@1.1.0: content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2914,6 +2923,9 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie-signature@1.2.2: cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'} engines: {node: '>=6.6.0'}
@@ -3182,6 +3194,10 @@ packages:
destr@2.0.5: destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3567,6 +3583,10 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
express@4.22.2:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'}
express@5.2.1: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -3664,6 +3684,10 @@ packages:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
finalhandler@1.3.2:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
finalhandler@2.1.1: finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
@@ -3746,6 +3770,10 @@ packages:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fresh@2.0.0: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -4020,6 +4048,10 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4899,6 +4931,9 @@ packages:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'} engines: {node: '>=16.10'}
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
merge-descriptors@2.0.0: merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4938,6 +4973,11 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'} engines: {node: '>=18'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mime@2.6.0: mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -5051,6 +5091,10 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.0.0: negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -5328,6 +5372,9 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
path-to-regexp@3.3.0: path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
@@ -5581,6 +5628,10 @@ packages:
rapiq@0.9.0: rapiq@0.9.0:
resolution: {integrity: sha512-k4oT4RarFBrlLMJ49xUTeQpa/us0uU4I70D/UEnK3FWQ4GENzei01rEQAmvPKAIzACo4NMW+YcYJ7EVfSa7EFg==} resolution: {integrity: sha512-k4oT4RarFBrlLMJ49xUTeQpa/us0uU4I70D/UEnK3FWQ4GENzei01rEQAmvPKAIzACo4NMW+YcYJ7EVfSa7EFg==}
raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
raw-body@3.0.2: raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -5835,10 +5886,18 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
send@1.2.1: send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
serve-static@2.2.1: serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -8381,7 +8440,7 @@ snapshots:
'@types/conventional-commits-parser@5.0.2': '@types/conventional-commits-parser@5.0.2':
dependencies: dependencies:
'@types/node': 25.9.1 '@types/node': 20.19.41
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
@@ -8421,6 +8480,13 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/express-serve-static-core@4.19.8':
dependencies:
'@types/node': 20.19.41
'@types/qs': 6.15.1
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.1': '@types/express-serve-static-core@5.1.1':
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
@@ -8428,6 +8494,13 @@ snapshots:
'@types/range-parser': 1.2.7 '@types/range-parser': 1.2.7
'@types/send': 1.2.1 '@types/send': 1.2.1
'@types/express@4.17.25':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 4.19.8
'@types/qs': 6.15.1
'@types/serve-static': 1.15.10
'@types/express@5.0.6': '@types/express@5.0.6':
dependencies: dependencies:
'@types/body-parser': 1.19.6 '@types/body-parser': 1.19.6
@@ -8459,11 +8532,6 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 20.19.41
'@types/jsonwebtoken@9.0.5': '@types/jsonwebtoken@9.0.5':
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
@@ -8472,7 +8540,7 @@ snapshots:
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/ms@2.1.0': {} '@types/mime@1.3.5': {}
'@types/node@14.18.63': {} '@types/node@14.18.63': {}
@@ -8484,20 +8552,6 @@ snapshots:
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
'@types/passport-jwt@4.0.1':
dependencies:
'@types/jsonwebtoken': 9.0.10
'@types/passport-strategy': 0.2.38
'@types/passport-strategy@0.2.38':
dependencies:
'@types/express': 5.0.6
'@types/passport': 1.0.17
'@types/passport@1.0.17':
dependencies:
'@types/express': 5.0.6
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
@@ -8517,10 +8571,21 @@ snapshots:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
csstype: 3.2.3 csstype: 3.2.3
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 20.19.41
'@types/send@1.2.1': '@types/send@1.2.1':
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 20.19.41
'@types/send': 0.17.6
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
dependencies: dependencies:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
@@ -8801,6 +8866,11 @@ snapshots:
abbrev@1.1.1: {} abbrev@1.1.1: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
accepts@2.0.0: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.2 mime-types: 3.0.2
@@ -9129,6 +9199,8 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
array-flatten@1.1.1: {}
array-ify@1.0.0: {} array-ify@1.0.0: {}
array-includes@3.1.9: array-includes@3.1.9:
@@ -9364,6 +9436,23 @@ snapshots:
bluebird@3.4.7: {} bluebird@3.4.7: {}
body-parser@1.20.5:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.15.2
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -9693,6 +9782,10 @@ snapshots:
console-control-strings@1.1.0: {} console-control-strings@1.1.0: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
content-disposition@1.1.0: {} content-disposition@1.1.0: {}
content-type@1.0.5: {} content-type@1.0.5: {}
@@ -9716,6 +9809,8 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {} cookie-signature@1.2.2: {}
cookie@0.7.2: {} cookie@0.7.2: {}
@@ -9947,6 +10042,8 @@ snapshots:
destr@2.0.5: {} destr@2.0.5: {}
destroy@1.2.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-newline@3.1.0: {} detect-newline@3.1.0: {}
@@ -10477,6 +10574,42 @@ snapshots:
jest-message-util: 29.7.0 jest-message-util: 29.7.0
jest-util: 29.7.0 jest-util: 29.7.0
express@4.22.2:
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.5
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 1.3.2
fresh: 0.5.2
http-errors: 2.0.1
merge-descriptors: 1.0.3
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.13
proxy-addr: 2.0.7
qs: 6.15.2
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.19.2
serve-static: 1.16.3
setprototypeof: 1.2.0
statuses: 2.0.2
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
express@5.2.1: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@@ -10611,6 +10744,18 @@ snapshots:
filter-obj@1.1.0: {} filter-obj@1.1.0: {}
finalhandler@1.3.2:
dependencies:
debug: 2.6.9
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
finalhandler@2.1.1: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -10706,6 +10851,8 @@ snapshots:
dependencies: dependencies:
map-cache: 0.2.2 map-cache: 0.2.2
fresh@0.5.2: {}
fresh@2.0.0: {} fresh@2.0.0: {}
fs-constants@1.0.0: {} fs-constants@1.0.0: {}
@@ -11038,6 +11185,10 @@ snapshots:
husky@9.1.7: {} husky@9.1.7: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -12075,6 +12226,8 @@ snapshots:
meow@12.1.1: {} meow@12.1.1: {}
merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
@@ -12118,6 +12271,8 @@ snapshots:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
mime@1.6.0: {}
mime@2.6.0: {} mime@2.6.0: {}
mimic-fn@2.1.0: {} mimic-fn@2.1.0: {}
@@ -12234,6 +12389,8 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
negotiator@0.6.3: {}
negotiator@1.0.0: {} negotiator@1.0.0: {}
neo-async@2.6.2: {} neo-async@2.6.2: {}
@@ -12532,6 +12689,8 @@ snapshots:
lru-cache: 11.5.0 lru-cache: 11.5.0
minipass: 7.1.3 minipass: 7.1.3
path-to-regexp@0.1.13: {}
path-to-regexp@3.3.0: {} path-to-regexp@3.3.0: {}
path-to-regexp@8.4.2: {} path-to-regexp@8.4.2: {}
@@ -12742,6 +12901,13 @@ snapshots:
ebec: 1.1.1 ebec: 1.1.1
smob: 1.6.2 smob: 1.6.2
raw-body@2.5.3:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.4.24
unpipe: 1.0.0
raw-body@3.0.2: raw-body@3.0.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -13020,6 +13186,24 @@ snapshots:
semver@7.8.2: {} semver@7.8.2: {}
send@0.19.2:
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.1
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
send@1.2.1: send@1.2.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -13036,6 +13220,15 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.19.2
transitivePeerDependencies:
- supports-color
serve-static@2.2.1: serve-static@2.2.1:
dependencies: dependencies:
encodeurl: 2.0.0 encodeurl: 2.0.0
@@ -13407,9 +13600,9 @@ snapshots:
dependencies: dependencies:
'@scarf/scarf': 1.4.0 '@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@5.2.1): swagger-ui-express@5.0.1(express@4.22.2):
dependencies: dependencies:
express: 5.2.1 express: 4.22.2
swagger-ui-dist: 5.32.6 swagger-ui-dist: 5.32.6
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}