mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' into passenger/feat/iam-integration
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,4 +22,7 @@ coverage/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
.npmrc
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
@@ -46,9 +45,8 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"jose": "^5.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -64,9 +62,9 @@
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -439,6 +439,7 @@ model Seat {
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
bookingSeats BookingSeat[]
|
||||
blocks SeatBlock[]
|
||||
ticketSeats TicketSeat[]
|
||||
@@unique([coachId, row, col])
|
||||
@@unique([coachId, seatNumber])
|
||||
|
||||
@@ -629,6 +630,20 @@ model Ticket {
|
||||
validatorId String?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model TicketSeat {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
seatId String
|
||||
seatIndex Int @default(0)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
@@index([ticketId])
|
||||
@@index([seatId])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -113,8 +113,19 @@ async function main() {
|
||||
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
|
||||
if (existingSchedules.length > 0) {
|
||||
const scheduleIds = existingSchedules.map(s => s.id);
|
||||
// Delete in correct order to avoid foreign key constraints
|
||||
await prisma.bookingSeat.deleteMany({ where: { booking: { scheduleId: { in: scheduleIds } } } });
|
||||
const bookingIds = (
|
||||
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
|
||||
).map(b => b.id);
|
||||
// Delete booking children in FK-safe order before deleting the bookings themselves
|
||||
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
|
||||
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
||||
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
|
||||
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
||||
|
||||
@@ -245,6 +245,7 @@ Payment providers send notifications to:
|
||||
.addTag("Support", "FAQ management and live chat support")
|
||||
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
|
||||
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
|
||||
.addTag("Config", "System configuration and settings")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
// .addServer("https://api.edr-platform.com", "Production")
|
||||
.build();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -14,6 +14,63 @@ export class BookingsController {
|
||||
private guestService: GuestBookingService,
|
||||
) {}
|
||||
|
||||
@Get('my')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get logged-in user\'s booking history',
|
||||
description: 'Returns all bookings for the authenticated user with schedule and payment details'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' })
|
||||
getMyBookings(
|
||||
@Req() req: any,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const passengerId = req.user?.passengerId;
|
||||
if (!passengerId) throw new Error('Passenger ID not found in token');
|
||||
return this.service.findByPassengerId(passengerId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
@Get('by-device')
|
||||
@ApiOperation({
|
||||
summary: 'Get bookings by device ID',
|
||||
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
|
||||
})
|
||||
@ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' })
|
||||
@ApiResponse({ status: 400, description: 'Device ID is required' })
|
||||
getByDevice(
|
||||
@Query('deviceId') deviceId?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
if (!deviceId) throw new BadRequestException('Device ID is required');
|
||||
return this.service.findByDeviceId(deviceId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
@@ -108,6 +165,17 @@ export class BookingsController {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update booking details',
|
||||
description: 'Updates booking information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
update(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':bookingRef/modify')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -121,6 +189,28 @@ export class BookingsController {
|
||||
return this.service.modify(dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete booking (admin only)',
|
||||
description: 'Permanently deletes a booking record'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.service.delete(id);
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@ApiOperation({
|
||||
summary: 'Check if booking is in use',
|
||||
description: 'Returns list of modules/data that reference this booking'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
checkUsage(@Param('id') id: string) {
|
||||
return this.service.checkBookingUsage(id);
|
||||
}
|
||||
|
||||
@Delete(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -38,6 +38,147 @@ export class BookingsService {
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = { passengerId };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// Find user with this device ID
|
||||
const device = await this.prisma.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
include: { user: { include: { passenger: true } } },
|
||||
}).catch(() => null);
|
||||
|
||||
const searchConditions = search ? [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
] : [];
|
||||
|
||||
const where: any = {
|
||||
OR: [
|
||||
{ userAgent: deviceId },
|
||||
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
if (search) {
|
||||
where.AND = [{ OR: searchConditions }];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
@@ -154,7 +295,6 @@ export class BookingsService {
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
@@ -302,6 +442,60 @@ export class BookingsService {
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
return this.prisma.booking.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status || booking.status,
|
||||
totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor,
|
||||
displayCurrency: dto.displayCurrency || booking.displayCurrency,
|
||||
displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor,
|
||||
},
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
|
||||
await this.prisma.booking.delete({ where: { id } });
|
||||
|
||||
return { deleted: true, bookingRef: booking.bookingRef };
|
||||
}
|
||||
|
||||
async checkBookingUsage(id: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([
|
||||
this.prisma.ticket.count({ where: { bookingId: id } }),
|
||||
this.prisma.paymentIntent.count({ where: { bookingId: id } }),
|
||||
this.prisma.bookingModification.count({ where: { bookingId: id } }),
|
||||
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (ticketCount > 0) usage.push('Ticket(s)');
|
||||
if (paymentIntentCount > 0) usage.push('Payment record(s)');
|
||||
if (modificationsCount > 0) usage.push('Modification history');
|
||||
if (cancellationCount > 0) usage.push('Cancellation record(s)');
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
};
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expirePendingBookings() {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
|
||||
@@ -168,12 +168,19 @@ export class GuestBookingService {
|
||||
throw new BadRequestException('Email already registered. Please login instead.');
|
||||
}
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: firstPassenger.phone || '',
|
||||
phone: accountPhone,
|
||||
passwordHash,
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
@@ -201,11 +208,19 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Use a guaranteed-unique guest phone to avoid constraint collisions
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existingPhone) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
@@ -235,6 +250,7 @@ export class GuestBookingService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
userAgent: dto.deviceId,
|
||||
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
|
||||
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
|
||||
seats: {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
|
||||
import { FaydaConfig } from '../../config/fayda.config';
|
||||
|
||||
@ApiTags('Fare Engine')
|
||||
@Controller('fare-engine')
|
||||
export class FareEngineController {
|
||||
constructor(private service: FareEngineService) {}
|
||||
constructor(
|
||||
private service: FareEngineService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({
|
||||
@@ -63,3 +68,36 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Config')
|
||||
@Controller('config')
|
||||
export class ConfigController {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
@Get('fayda-status')
|
||||
@ApiOperation({
|
||||
summary: 'Check Verifayda 2.0 configuration status',
|
||||
description: 'Returns whether Verifayda integration is enabled and ready to use'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Verifayda status retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
enabled: true,
|
||||
mode: 'production',
|
||||
apiUrl: 'https://api.verifayda.gov.et/v2'
|
||||
}
|
||||
}
|
||||
})
|
||||
getFaydaStatus() {
|
||||
const faydaConfig = this.configService.get<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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FareEngineController } from './fare-engine.controller';
|
||||
import { FareEngineController, ConfigController } from './fare-engine.controller';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
import { CurrencyController } from './currency.controller';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule],
|
||||
controllers: [FareEngineController, CurrencyController],
|
||||
controllers: [FareEngineController, CurrencyController, ConfigController],
|
||||
providers: [FareEngineService],
|
||||
exports: [FareEngineService],
|
||||
})
|
||||
|
||||
@@ -22,6 +22,14 @@ export class FleetController {
|
||||
@ApiResponse({ status: 201, description: 'Train created' })
|
||||
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
|
||||
|
||||
@Patch('trains/:id')
|
||||
@ApiOperation({ summary: 'Update a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 200, description: 'Train updated' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
|
||||
|
||||
@Get('coaches')
|
||||
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
|
||||
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
|
||||
|
||||
@@ -112,6 +112,12 @@ export class FleetService {
|
||||
|
||||
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
|
||||
|
||||
async updateTrain(id: string, dto: CreateTrainDto) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
return this.prisma.train.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async getCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@ApiTags('Passengers')
|
||||
@Controller('passengers')
|
||||
@@ -12,6 +13,7 @@ export class PassengersController {
|
||||
constructor(
|
||||
private service: PassengersService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -37,6 +39,42 @@ export class PassengersController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get current passenger profile',
|
||||
description: 'Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Passenger profile retrieved successfully or null if not found'
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||
async getMe(@Request() req: any) {
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: req.user.userId },
|
||||
include: {
|
||||
passenger: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.passenger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.service.getProfile(user.passenger.id);
|
||||
} catch (error) {
|
||||
// If profile lookup fails for any reason, return null to allow app to continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/profile')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -313,4 +351,37 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
getSavedRoutes(@Param('id') id: string) {
|
||||
return this.service.getSavedRoutes(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update passenger details',
|
||||
description: 'Updates passenger information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Passenger updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Passenger not found' })
|
||||
updatePassenger(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updatePassenger(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete passenger (admin only)',
|
||||
description: 'Permanently deletes a passenger record and associated data'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Passenger deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Passenger not found' })
|
||||
deletePassenger(@Param('id') id: string) {
|
||||
return this.service.deletePassenger(id);
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@ApiOperation({
|
||||
summary: 'Check if passenger is in use',
|
||||
description: 'Returns list of modules/data that reference this passenger'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Passenger not found' })
|
||||
checkUsage(@Param('id') id: string) {
|
||||
return this.service.checkPassengerUsage(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
|
||||
import { PassengersController } from './passengers.controller';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [VerifaydaModule, HttpModule],
|
||||
imports: [VerifaydaModule, HttpModule, PrismaModule],
|
||||
controllers: [PassengersController],
|
||||
providers: [PassengersService]
|
||||
})
|
||||
|
||||
@@ -180,6 +180,28 @@ export class PassengersService {
|
||||
|
||||
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
|
||||
|
||||
async updatePassenger(id: string, dto: any) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
return this.prisma.passenger.update({
|
||||
where: { id },
|
||||
data: {
|
||||
user: {
|
||||
update: {
|
||||
fullName: dto.fullName || undefined,
|
||||
email: dto.email || undefined,
|
||||
phone: dto.phone || undefined,
|
||||
nationality: dto.nationality || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
|
||||
loyalty: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async registerPassenger(dto: RegisterPassengerDto) {
|
||||
const isEthiopian = !!dto.nationalId;
|
||||
const isLoggedIn = !!dto.userId;
|
||||
@@ -270,4 +292,30 @@ export class PassengersService {
|
||||
message: 'Passenger details saved for guest booking',
|
||||
};
|
||||
}
|
||||
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
await this.prisma.passenger.delete({ where: { id } });
|
||||
return { deleted: true, passengerId: id };
|
||||
}
|
||||
|
||||
async checkPassengerUsage(id: string) {
|
||||
const [bookingCount, loyaltyAccount, walletAccount] = await Promise.all([
|
||||
this.prisma.booking.count({ where: { passengerId: id } }),
|
||||
this.prisma.loyaltyAccount.findUnique({ where: { passengerId: id } }),
|
||||
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
|
||||
if (loyaltyAccount) usage.push('Loyalty account');
|
||||
if (walletAccount) usage.push('Wallet account');
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { RolesGuard } from '../../common/roles.guard';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
@@ -62,4 +63,113 @@ export class PaymentsController {
|
||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
||||
|
||||
@Get('checkout')
|
||||
@ApiOperation({
|
||||
summary: 'Browser checkout redirect',
|
||||
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
|
||||
})
|
||||
@ApiQuery({ name: 'bookingId', required: true })
|
||||
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
||||
@ApiProduces('text/html')
|
||||
async checkout(
|
||||
@Query('bookingId') bookingId: string,
|
||||
@Query('method') method: PaymentMethodTypeEnum,
|
||||
@Query('platform') platform: PaymentPlatformDto = 'web',
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId'));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method'));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
||||
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url));
|
||||
}
|
||||
|
||||
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
||||
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/"/g, '"');
|
||||
return `<!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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,8 +182,6 @@ export class PaymentsService {
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private formatIntentResponse(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): InitiateResponseDto {
|
||||
@@ -349,9 +347,31 @@ export class PaymentsService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.ticketsService.generate(booking.id);
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
try {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createJourneySegments(booking);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
@@ -390,4 +410,47 @@ export class PaymentsService {
|
||||
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
|
||||
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
|
||||
}
|
||||
|
||||
private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: booking.scheduleId },
|
||||
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (!schedule) return;
|
||||
|
||||
const stopTimes = schedule.stopTimes;
|
||||
if (stopTimes.length < 2) return;
|
||||
|
||||
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
|
||||
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
|
||||
|
||||
if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return;
|
||||
|
||||
const journey = await this.prisma.journey.create({
|
||||
data: {
|
||||
passengerId: booking.passengerId,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
});
|
||||
|
||||
const journeySegments = [];
|
||||
for (const bookingSeat of booking.seats) {
|
||||
for (let i = originSequence; i < destSequence; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: booking.scheduleId,
|
||||
segmentOrder: i,
|
||||
seatId: bookingSeat.seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (journeySegments.length > 0) {
|
||||
await this.prisma.journeySegment.createMany({ data: journeySegments });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking ${input.bookingRef}`,
|
||||
title: `EDR Booking`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
|
||||
@@ -353,9 +353,13 @@ export class SeatsService {
|
||||
// No-op for status — availability is segment-scoped via JourneySegment
|
||||
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
||||
}
|
||||
|
||||
async releaseSeats(seatIds: string[]) {
|
||||
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
|
||||
// For segment-based bookings, releasing is handled by JourneySegment deletion
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.journeySegment.deleteMany({
|
||||
where: { seatId: { in: seatIds } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
||||
@@ -480,6 +484,16 @@ export class SeatsService {
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
|
||||
for (const hold of expired) {
|
||||
await this.releaseSeats(hold.seatIds);
|
||||
try {
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
} catch (err) {
|
||||
// Ignore if already deleted (e.g., by another process)
|
||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@Controller('tickets')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
})
|
||||
generateTicket(@Param('bookingId') bookingId: string) {
|
||||
return this.service.generate(bookingId);
|
||||
}
|
||||
|
||||
@Patch('update-seats/:bookingId')
|
||||
@ApiOperation({
|
||||
summary: 'Update ticket seats before final confirmation',
|
||||
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
|
||||
})
|
||||
updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) {
|
||||
return this.service.updateSeats(bookingId, body.seatIds);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listTickets({
|
||||
search,
|
||||
status,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details',
|
||||
description: `Returns ticket information including:
|
||||
@@ -26,6 +62,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
|
||||
@@ -39,20 +77,37 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
return this.service.getValidationLogs(ticketId);
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.exportOfflineData(scheduleId);
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Delete ticket (admin only)',
|
||||
description: 'Permanently deletes a ticket record and removes associated seat blocks'
|
||||
})
|
||||
delete(@Param('id') id: string) {
|
||||
return this.service.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] })
|
||||
@Module({
|
||||
controllers: [TicketsController],
|
||||
providers: [TicketsService, JwtGuard],
|
||||
exports: [TicketsService, JwtGuard],
|
||||
})
|
||||
export class TicketsModule {}
|
||||
|
||||
export { TicketsController } from './tickets.controller';
|
||||
|
||||
@@ -13,19 +13,142 @@ interface OfflineValidation {
|
||||
export class TicketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
where.booking = { status: filters.status };
|
||||
}
|
||||
const tickets = await this.prisma.ticket.findMany({
|
||||
where,
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
skip: filters.skip,
|
||||
take: filters.take,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
});
|
||||
const total = await this.prisma.ticket.count({ where });
|
||||
return {
|
||||
items: tickets.map((t) => ({
|
||||
id: t.id,
|
||||
ticketNumber: t.barcodePayload,
|
||||
bookingRef: t.bookingRef,
|
||||
booking: {
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
||||
contactEmail: t.booking.contactEmail,
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
status: t.booking.status,
|
||||
validatedAt: t.validatedAt,
|
||||
createdAt: t.issuedAt,
|
||||
})),
|
||||
total,
|
||||
skip: filters.skip,
|
||||
take: filters.take,
|
||||
};
|
||||
}
|
||||
|
||||
async generate(bookingId: string) {
|
||||
if (!bookingId) {
|
||||
throw new BadRequestException('Booking ID is required');
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
|
||||
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
return this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
|
||||
// Create permanent seat blocks for all booked seats
|
||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of seatIds) {
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `Permanently booked in ticket ${ticket.id}`,
|
||||
blockedBy: 'SYSTEM',
|
||||
approvedBy: 'SYSTEM',
|
||||
}
|
||||
}).catch(() => null); // Ignore if already exists
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async updateSeats(bookingId: string, newSeatIds: string[]) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, ticket: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
|
||||
|
||||
// Remove old seat blocks
|
||||
const oldSeatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of oldSeatIds) {
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: {
|
||||
seatId,
|
||||
reason: { contains: booking.ticket.id }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove old booking seats
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Create new seat blocks
|
||||
for (const seatId of newSeatIds) {
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `Permanently booked in ticket ${booking.ticket.id}`,
|
||||
blockedBy: 'SYSTEM',
|
||||
approvedBy: 'SYSTEM',
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// Create new booking seats (placeholder with minimal data)
|
||||
for (let i = 0; i < newSeatIds.length; i++) {
|
||||
await this.prisma.bookingSeat.create({
|
||||
data: {
|
||||
bookingId,
|
||||
seatId: newSeatIds[i],
|
||||
passengerName: `Passenger ${i + 1}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, updatedSeats: newSeatIds.length };
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
@@ -147,4 +270,22 @@ export class TicketsService {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
|
||||
|
||||
// Remove seat blocks associated with this ticket
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: {
|
||||
reason: { contains: id }
|
||||
}
|
||||
});
|
||||
|
||||
await this.prisma.ticket.delete({ where: { id } });
|
||||
|
||||
return { deleted: true, ticketId: id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Filter, Download, Eye, XCircle } from 'lucide-react';
|
||||
import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { bookingsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { BookingFilters } from '@/types';
|
||||
|
||||
@@ -18,6 +20,10 @@ export default function BookingsPage() {
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -34,16 +40,46 @@ export default function BookingsPage() {
|
||||
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
alert('Booking cancelled successfully');
|
||||
setSuccessMessage('Booking cancelled successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
setDeleteConfirmOpen(false);
|
||||
setBookingToDelete(null);
|
||||
setSuccessMessage('Booking deleted successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setDeleteConfirmOpen(false);
|
||||
alert(`Error: ${error.message || 'Failed to delete booking'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancel = async (booking: any) => {
|
||||
if (confirm(`Are you sure you want to cancel booking ${booking.bookingRef}?`)) {
|
||||
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
|
||||
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (booking: any) => {
|
||||
setBookingToDelete(booking);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (bookingToDelete) {
|
||||
await deleteMutation.mutateAsync(bookingToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'bookingRef',
|
||||
@@ -94,13 +130,12 @@ export default function BookingsPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
// TODO: Create booking detail page
|
||||
// {
|
||||
// label: 'View Details',
|
||||
// onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`,
|
||||
// variant: 'secondary' as const,
|
||||
// icon: Eye,
|
||||
// },
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (booking: any) => setSelectedBooking(booking),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Cancel Booking',
|
||||
onClick: handleCancel,
|
||||
@@ -108,6 +143,12 @@ export default function BookingsPage() {
|
||||
icon: XCircle,
|
||||
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDeleteClick,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -121,6 +162,11 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
<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 bookings: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
@@ -166,6 +212,163 @@ export default function BookingsPage() {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { fleetApi } from '@/lib/api';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function CoachesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCoach, setEditingCoach] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -65,9 +67,14 @@ export default function CoachesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (coach: any) => {
|
||||
if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) {
|
||||
await deleteMutation.mutateAsync(coach.id);
|
||||
const handleDelete = (coach: any) => {
|
||||
setDeleteConfirm({ isOpen: true, coach });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.coach) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
|
||||
setDeleteConfirm({ isOpen: false, coach: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,6 +204,18 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function LoginPage() {
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UserPlus, Download, Eye } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { passengersApi } from '@/lib/api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { passengersApi, apiClient } from '@/lib/api';
|
||||
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||
import { PassengerFilters } from '@/types';
|
||||
|
||||
export default function PassengersPage() {
|
||||
@@ -17,6 +19,28 @@ export default function PassengersPage() {
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
});
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<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({
|
||||
queryKey: ['passengers', filters],
|
||||
@@ -65,14 +89,19 @@ export default function PassengersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const actions: any[] = [
|
||||
// TODO: Create passenger detail page
|
||||
// {
|
||||
// label: 'View Details',
|
||||
// onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`,
|
||||
// variant: 'secondary' as const,
|
||||
// icon: Eye,
|
||||
// },
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (passenger: any) => setSelectedPassenger(passenger),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -130,6 +159,184 @@ export default function PassengersPage() {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { stationsApi } from '@/lib/api';
|
||||
|
||||
@@ -24,6 +25,7 @@ export default function RoutesPage() {
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||
@@ -148,9 +150,14 @@ export default function RoutesPage() {
|
||||
return origin && dest ? `${origin.name} - ${dest.name}` : '';
|
||||
};
|
||||
|
||||
const handleDelete = async (route: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
|
||||
await deleteMutation.mutateAsync(route.id);
|
||||
const handleDelete = (route: any) => {
|
||||
setDeleteConfirm({ isOpen: true, route });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.route) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.route.id);
|
||||
setDeleteConfirm({ isOpen: false, route: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,6 +253,18 @@ export default function RoutesPage() {
|
||||
emptyMessage="No routes found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -261,6 +280,12 @@ export default function RoutesPage() {
|
||||
size="lg"
|
||||
>
|
||||
<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>
|
||||
<label className="label">Origin Station *</label>
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { seatClassesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function SeatClassesPage() {
|
||||
const [filters, setFilters] = useState({ search: '' });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -63,9 +65,14 @@ export default function SeatClassesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (seatClass: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) {
|
||||
await deleteMutation.mutateAsync(seatClass.id);
|
||||
const handleDelete = (seatClass: any) => {
|
||||
setDeleteConfirm({ isOpen: true, seatClass });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.seatClass) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.seatClass.id);
|
||||
setDeleteConfirm({ isOpen: false, seatClass: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +105,7 @@ export default function SeatClassesPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -132,6 +139,18 @@ export default function SeatClassesPage() {
|
||||
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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { stationsApi } from '@/lib/api';
|
||||
import { Station } from '@/types';
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function StationsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingStation, setEditingStation] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -67,9 +69,14 @@ export default function StationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (station: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${station.name}?`)) {
|
||||
await deleteMutation.mutateAsync(station.id);
|
||||
const handleDelete = (station: any) => {
|
||||
setDeleteConfirm({ isOpen: true, station });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.station) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.station.id);
|
||||
setDeleteConfirm({ isOpen: false, station: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,6 +230,18 @@ export default function StationsPage() {
|
||||
emptyMessage="No stations found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -234,6 +253,12 @@ export default function StationsPage() {
|
||||
size="lg"
|
||||
>
|
||||
<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>
|
||||
<label className="label">Station Code *</label>
|
||||
|
||||
@@ -2,27 +2,35 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, RefreshCw, CheckCircle } from 'lucide-react';
|
||||
import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { ticketsApi } from '@/lib/api';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { ticketsApi, apiClient } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '' });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tickets', filters],
|
||||
queryFn: () => ticketsApi.getAll(filters),
|
||||
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: ticketsApi.regenerate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
alert('Ticket regenerated successfully');
|
||||
setSuccessMessage('Ticket regenerated successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Error: ${error.message || 'Failed to regenerate ticket'}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,12 +38,31 @@ export default function TicketsPage() {
|
||||
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
alert('Ticket validated successfully');
|
||||
setSuccessMessage('Ticket validated successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Error: ${error.message || 'Failed to validate ticket'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
setDeleteConfirmOpen(false);
|
||||
setTicketToDelete(null);
|
||||
setSuccessMessage('Ticket deleted successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setDeleteConfirmOpen(false);
|
||||
alert(`Error: ${error.message || 'Failed to delete ticket'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegenerate = async (ticket: any) => {
|
||||
if (confirm(`Regenerate ticket ${ticket.ticketNumber}?`)) {
|
||||
if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) {
|
||||
await regenerateMutation.mutateAsync(ticket.id);
|
||||
}
|
||||
};
|
||||
@@ -47,6 +74,17 @@ export default function TicketsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ticket: any) => {
|
||||
setTicketToDelete(ticket);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (ticketToDelete) {
|
||||
await deleteMutation.mutateAsync(ticketToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'ticketNumber',
|
||||
@@ -121,15 +159,8 @@ export default function TicketsPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
// TODO: Create ticket detail page
|
||||
// {
|
||||
// label: 'View Details',
|
||||
// onClick: (ticket: any) => window.location.href = `/tickets/${ticket.id}`,
|
||||
// variant: 'secondary' as const,
|
||||
// icon: Eye,
|
||||
// },
|
||||
{
|
||||
label: 'Validate',
|
||||
label: 'Check-in',
|
||||
onClick: handleValidate,
|
||||
variant: 'primary' as const,
|
||||
icon: CheckCircle,
|
||||
@@ -141,6 +172,12 @@ export default function TicketsPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDeleteClick,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -155,6 +192,16 @@ export default function TicketsPage() {
|
||||
|
||||
{/* Filters */}
|
||||
<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>
|
||||
<label className="label">Search</label>
|
||||
@@ -191,6 +238,22 @@ export default function TicketsPage() {
|
||||
loading={isLoading}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Plus, Edit, Trash2, Train } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { fleetApi } from '@/lib/api';
|
||||
import { Train as TrainType } from '@/types';
|
||||
@@ -14,6 +15,7 @@ import { formatDate } from '@/lib/utils';
|
||||
export default function TrainsPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -28,6 +30,10 @@ export default function TrainsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||
setShowModal(false);
|
||||
setEditingTrain(null);
|
||||
alert('Train created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,9 +43,32 @@ export default function TrainsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||
setShowModal(false);
|
||||
setEditingTrain(null);
|
||||
alert('Train updated successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTrainMutation = useMutation({
|
||||
mutationFn: (id: string) => fleetApi.deleteTrain(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||
alert('Train deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (train: TrainType) => {
|
||||
setDeleteConfirm({ isOpen: true, train });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.train) {
|
||||
await deleteTrainMutation.mutateAsync(deleteConfirm.train.id);
|
||||
setDeleteConfirm({ isOpen: false, train: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const trainData = {
|
||||
number: formData.get('number') as string,
|
||||
@@ -113,6 +142,12 @@ export default function TrainsPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -142,6 +177,18 @@ export default function TrainsPage() {
|
||||
emptyMessage="No trains found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -160,6 +207,12 @@ export default function TrainsPage() {
|
||||
}}
|
||||
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>
|
||||
<label className="label">Train Number *</label>
|
||||
|
||||
@@ -62,7 +62,7 @@ const navigationSections = [
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
||||
{ name: 'Seat Classes', href: '/seat-classes', icon: Settings },
|
||||
{ name: 'Classes', href: '/seat-classes', icon: Settings },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,6 @@ const navigationSections = [
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||
{ name: 'Wallet Management', href: '/wallet', icon: Wallet },
|
||||
{ name: 'Promotions', href: '/promotions', icon: Gift },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
|
||||
return (
|
||||
<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={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background shadow-xl flex flex-col max-h-[90vh]`}>
|
||||
<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>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
<div className="overflow-y-auto flex-1 px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
|
||||
// Export apiClient for direct use
|
||||
export { apiClient };
|
||||
|
||||
// Bookings API
|
||||
export const bookingsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
@@ -15,8 +18,32 @@ export const bookingsApi = {
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
},
|
||||
getById: (id: string) => apiClient.get<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),
|
||||
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
|
||||
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
||||
};
|
||||
|
||||
// Passengers API
|
||||
@@ -140,7 +167,10 @@ export const paymentsApi = {
|
||||
// Tickets API
|
||||
export const ticketsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params as Record<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}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
|
||||
@@ -8,16 +8,25 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => {
|
||||
return format(new Date(date), formatStr);
|
||||
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, formatStr);
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: string | Date): string => {
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
export const formatDateTime = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeLocal = (date: string | Date): string => {
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
export const formatDateTimeLocal = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
|
||||
@@ -55,64 +55,136 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-card text-card-foreground rounded-lg shadow-sm border border-border p-6;
|
||||
background-color: hsl(var(--card));
|
||||
color: hsl(var(--card-foreground));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition-property: background-color;
|
||||
transition-duration: 200ms;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] dark:bg-[rgb(20,113,76)] dark:hover:bg-[rgb(16,90,61)] shadow-md;
|
||||
background-color: rgb(20, 113, 76);
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: rgb(16, 90, 61);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
background-color: hsl(var(--secondary));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-[rgb(20,113,76)] text-destructive-foreground hover:bg-[rgb(16,90,61)];
|
||||
background-color: rgb(20, 113, 76);
|
||||
color: hsl(var(--destructive-foreground));
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgb(16, 90, 61);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--input));
|
||||
border-radius: 0.5rem;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
ring: 2px hsl(var(--ring));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-foreground mb-1;
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.gradient-edr {
|
||||
@apply bg-[rgb(20,113,76)];
|
||||
background-color: rgb(20, 113, 76);
|
||||
}
|
||||
|
||||
.gradient-edr-bg {
|
||||
@apply bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900;
|
||||
background: linear-gradient(to bottom, #0f172a, #1e293b, #0f172a);
|
||||
}
|
||||
|
||||
.edr-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edr-badge-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dark .edr-badge-success {
|
||||
background-color: rgb(6 78 59 / 0.3);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.edr-badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
|
||||
background-color: #fef3c7;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.dark .edr-badge-warning {
|
||||
background-color: rgb(120 53 15 / 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.edr-badge-danger {
|
||||
@apply bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400;
|
||||
background-color: #dcfce7;
|
||||
color: rgb(20, 113, 76);
|
||||
}
|
||||
|
||||
.dark .edr-badge-danger {
|
||||
background-color: rgb(6 78 59 / 0.3);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.edr-badge-info {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.dark .edr-badge-info {
|
||||
background-color: rgb(30 58 138 / 0.3);
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
card: 'hsl(var(--card))',
|
||||
'card-foreground': 'hsl(var(--card-foreground))',
|
||||
popover: 'hsl(var(--popover))',
|
||||
'popover-foreground': 'hsl(var(--popover-foreground))',
|
||||
primary: 'hsl(var(--primary))',
|
||||
'primary-foreground': 'hsl(var(--primary-foreground))',
|
||||
secondary: 'hsl(var(--secondary))',
|
||||
'secondary-foreground': 'hsl(var(--secondary-foreground))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted-foreground))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
'accent-foreground': 'hsl(var(--accent-foreground))',
|
||||
destructive: 'hsl(var(--destructive))',
|
||||
'destructive-foreground': 'hsl(var(--destructive-foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
edr: {
|
||||
blue: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
orange: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fed7aa',
|
||||
300: '#fdba74',
|
||||
400: '#fb923c',
|
||||
500: '#f97316',
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
800: '#9a3412',
|
||||
900: '#7c2d12',
|
||||
},
|
||||
red: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
output: 'export',
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: true, // Required for static export
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
12
apps/edr-passenger-web/portal/public/README.md
Normal file
12
apps/edr-passenger-web/portal/public/README.md
Normal 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.
|
||||
BIN
apps/edr-passenger-web/portal/public/banner.jpg
Normal file
BIN
apps/edr-passenger-web/portal/public/banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 734 KiB |
411
apps/edr-passenger-web/portal/src/app/about/page.tsx
Normal file
411
apps/edr-passenger-web/portal/src/app/about/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default function AuthCheckPage() {
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Sign in to access saved profiles or continue as a guest
|
||||
</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">
|
||||
<LogIn className="w-10 h-10 text-white" />
|
||||
</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">
|
||||
Access your saved passenger profiles and booking history for faster checkout
|
||||
</p>
|
||||
@@ -59,26 +59,26 @@ export default function AuthCheckPage() {
|
||||
<div className="space-y-3 mb-6 text-left">
|
||||
<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">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary w-full">
|
||||
Sign In to Continue
|
||||
<button className="btn-primary w-full text-center">
|
||||
Sign in to continue
|
||||
</button>
|
||||
</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">
|
||||
<UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" />
|
||||
</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">
|
||||
Book without an account. You can create one after completing your booking
|
||||
</p>
|
||||
@@ -119,8 +119,8 @@ export default function AuthCheckPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary w-full">
|
||||
Continue as Guest
|
||||
<button className="btn-secondary w-full text-center">
|
||||
Continue as guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,31 +6,42 @@ import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type BookingWithTicket = {
|
||||
id: string;
|
||||
pnr?: string | null;
|
||||
status?: string;
|
||||
totalMinor?: number;
|
||||
ticket?: {
|
||||
barcodePayload?: string;
|
||||
qrPayload?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ConfirmationPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const confirmAttempted = useRef(false);
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
|
||||
});
|
||||
|
||||
const { data: _booking } = useQuery({
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
try {
|
||||
return await apiClient.get(`/bookings/${bookingId}`);
|
||||
} catch (error) {
|
||||
console.log('Booking API not available, using local data');
|
||||
// Return mock booking data
|
||||
return {
|
||||
id: bookingId,
|
||||
pnr,
|
||||
id: bookingId || '',
|
||||
pnr: pnr || undefined,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
};
|
||||
@@ -40,10 +51,15 @@ export default function ConfirmationPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && !confirmMutation.isSuccess && !confirmMutation.isPending) {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
confirmMutation.mutate();
|
||||
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
});
|
||||
}
|
||||
}, [bookingId]);
|
||||
}, [bookingId, confirmMutation]);
|
||||
|
||||
const copyPNR = () => {
|
||||
if (pnr) {
|
||||
@@ -54,7 +70,6 @@ export default function ConfirmationPage() {
|
||||
};
|
||||
|
||||
const handleDownloadTickets = () => {
|
||||
// Mock download - in production this would call the API
|
||||
alert('Ticket download will be available soon. Your tickets are displayed below.');
|
||||
};
|
||||
|
||||
@@ -71,15 +86,18 @@ export default function ConfirmationPage() {
|
||||
router.push('/booking/search');
|
||||
};
|
||||
|
||||
if (!bookingId || !pnr) {
|
||||
router.push('/booking/search');
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
if (!bookingId || !pnr) return null;
|
||||
|
||||
return (
|
||||
<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-5xl mx-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* 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="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">
|
||||
<span className="text-5xl font-bold tracking-widest">{pnr}</span>
|
||||
<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">
|
||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||
</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 className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -161,11 +179,12 @@ export default function ConfirmationPage() {
|
||||
|
||||
{/* Tickets */}
|
||||
<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">
|
||||
{passengers.map((passenger, index) => {
|
||||
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||
const qrData = JSON.stringify({
|
||||
const backendTicket = _booking?.ticket || null;
|
||||
const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||
const qrData = backendTicket?.qrPayload || JSON.stringify({
|
||||
pnr,
|
||||
ticketNumber,
|
||||
passengerName: passenger.name,
|
||||
@@ -201,7 +220,7 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -263,7 +282,7 @@ export default function ConfirmationPage() {
|
||||
onClick={handleNewBooking}
|
||||
className="btn-primary w-full py-4 text-lg font-semibold"
|
||||
>
|
||||
Book Another Trip
|
||||
Book another trip
|
||||
</button>
|
||||
|
||||
{/* Info Notices */}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -29,10 +27,9 @@ const passengerSchema = z.object({
|
||||
faydaSub: z.string().optional(),
|
||||
formExpanded: z.boolean().optional(),
|
||||
}).refine((data) => {
|
||||
// For non-Ethiopian passengers, passport number and country are required
|
||||
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
|
||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
||||
data.passportCountry && data.passportCountry.length > 0;
|
||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
||||
data.passportCountry && data.passportCountry.length > 0;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
@@ -49,12 +46,13 @@ type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore();
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
const [faydaEnabled, setFaydaEnabled] = useState(true);
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||
const [updatingUser, setUpdatingUser] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formInitialized, setFormInitialized] = useState(false);
|
||||
const [nationalityMismatch, setNationalityMismatch] = useState(false);
|
||||
|
||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||
|
||||
@@ -94,26 +92,82 @@ export default function PassengersPage() {
|
||||
}
|
||||
};
|
||||
checkFaydaStatus();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated && user && searchCriteria?.nationality === 'ETHIOPIAN') {
|
||||
if (user.faydaVerified && user.fullName && user.dateOfBirth) {
|
||||
setValue('passengers.0.name', user.fullName);
|
||||
setValue('passengers.0.dateOfBirth', user.dateOfBirth);
|
||||
setValue('passengers.0.gender', user.gender as any);
|
||||
setValue('passengers.0.nationality', user.nationality || 'ETHIOPIAN');
|
||||
setValue('passengers.0.phone', user.phone || '');
|
||||
setValue('passengers.0.email', user.email || '');
|
||||
setValue('passengers.0.faydaVerified', true);
|
||||
setValue('passengers.0.faydaSub', user.faydaSub || '');
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
setVerificationStatus({ 0: 'success' });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.faydaVerified) {
|
||||
setVerificationStatus({ 0: 'success' });
|
||||
}
|
||||
}, [isAuthenticated, user?.faydaVerified]);
|
||||
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
console.log('Missing required data for population');
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch passenger profile from backend
|
||||
const passengerData: any = await apiClient.get(`/passengers/me`);
|
||||
console.log('Fetched passenger data:', passengerData);
|
||||
|
||||
if (!passengerData) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userNationality = (passengerData?.nationality || user.nationality || '').toUpperCase().trim();
|
||||
const searchNationality = (searchCriteria?.nationality || '').toUpperCase().trim();
|
||||
console.log('Nationalities:', { userNationality, searchNationality });
|
||||
|
||||
// Check for nationality mismatch
|
||||
if (userNationality !== searchNationality) {
|
||||
console.log('Nationality mismatch detected');
|
||||
setNationalityMismatch(true);
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only populate if nationalities match
|
||||
console.log('Setting passenger 0 values');
|
||||
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
|
||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
||||
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
|
||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
|
||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
||||
setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false);
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
|
||||
setFormInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger data:', error);
|
||||
setFormInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
populateForm();
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nationalityMismatch && formInitialized) {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById('nationality-mismatch');
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}, [nationalityMismatch, formInitialized]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
purpose: 'PURCHASE',
|
||||
@@ -126,7 +180,7 @@ export default function PassengersPage() {
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
'FaydaVerification',
|
||||
@@ -170,6 +224,19 @@ export default function PassengersPage() {
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let passengerId = '';
|
||||
|
||||
// For authenticated users, fetch the passenger profile to get the passengerId
|
||||
if (isAuthenticated && user?.id) {
|
||||
try {
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id || '';
|
||||
console.log('Fetched passengerId:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -181,20 +248,29 @@ export default function PassengersPage() {
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
|
||||
// Save passengerId to booking store for later use
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('Saved passengerId to booking store:', passengerId);
|
||||
}
|
||||
|
||||
router.push('/booking/seats');
|
||||
} catch (error) {
|
||||
console.error('Failed to save passenger details:', error);
|
||||
@@ -204,16 +280,71 @@ export default function PassengersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!searchCriteria) {
|
||||
router.push('/booking/search');
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if (!searchCriteria) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}, [searchCriteria, router]);
|
||||
|
||||
if (!searchCriteria) return null;
|
||||
|
||||
if (nationalityMismatch && formInitialized) {
|
||||
const searchLabel: Record<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 (
|
||||
<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-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger Details</h1>
|
||||
<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>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
@@ -232,7 +363,7 @@ export default function PassengersPage() {
|
||||
Passenger {index + 1} {index === 0 && '(Primary)'}
|
||||
{index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'}
|
||||
<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>
|
||||
</h3>
|
||||
|
||||
@@ -249,27 +380,17 @@ export default function PassengersPage() {
|
||||
type="button"
|
||||
onClick={() => openFaydaVerification(index)}
|
||||
className="btn-primary flex items-center justify-center gap-2 mx-auto"
|
||||
disabled={updatingUser}
|
||||
>
|
||||
{updatingUser ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
)}
|
||||
{updatingUser ? 'Updating Profile...' : 'Verify with Fayda'}
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
{isLoggedInNotVerified ? 'Verify with Fayda' : 'Verify with Fayda'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto"
|
||||
>
|
||||
Skip for now
|
||||
</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>
|
||||
) : showManualEntryLink ? (
|
||||
<div className="text-center py-8">
|
||||
@@ -281,238 +402,261 @@ export default function PassengersPage() {
|
||||
onClick={() => toggleForm(index)}
|
||||
className="btn-primary"
|
||||
>
|
||||
Enter Details Manually
|
||||
Enter details manually
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isEthiopian ? (
|
||||
<>
|
||||
{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">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{isEthiopian ? (
|
||||
<>
|
||||
{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">
|
||||
<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
|
||||
</p>
|
||||
</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>
|
||||
<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
|
||||
{...register(`passengers.${index}.passportNumber`)}
|
||||
{...register(`passengers.${index}.name`)}
|
||||
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 && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
||||
{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">Passport Country *</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
placeholder="Djibouti"
|
||||
value={passengers[index]?.dateOfBirth || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, 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>
|
||||
{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">Issuing Authority</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportIssuingAuthority`)}
|
||||
<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"
|
||||
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>
|
||||
<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
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
placeholder="+251911234567"
|
||||
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">Expiry Date</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</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 className="card">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" {...register('createAccount')} className="w-4 h-4" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div className="card">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" {...register('createAccount')} className="w-4 h-4" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Continue to Seat Selection'}
|
||||
{saving ? 'Saving...' : 'Continue to seat selection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -160,10 +160,10 @@ export default function PaymentPage() {
|
||||
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-3xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete Payment</h1>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
@@ -173,14 +173,14 @@ export default function PaymentPage() {
|
||||
{paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
<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="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
@@ -212,8 +212,8 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total Amount</span>
|
||||
<span className="text-primary">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total amount</span>
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -223,7 +223,7 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Payment Methods */}
|
||||
<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">
|
||||
{paymentMethods.map((method) => {
|
||||
const Icon = method.icon;
|
||||
@@ -285,7 +285,7 @@ export default function PaymentPage() {
|
||||
disabled={isProcessing}
|
||||
className="btn-secondary w-full py-2"
|
||||
>
|
||||
Back to Review
|
||||
Back to review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No Trains Found</h2>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
We couldn't find any trains matching your search criteria. Try adjusting your dates or route.
|
||||
We couldn't find any trains matching your search criteria. <br /> Try adjusting your dates or route.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
|
||||
Modify Search
|
||||
Modify search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,18 +153,18 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
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="max-w-5xl mx-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<button
|
||||
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" />
|
||||
Modify Search
|
||||
Modify search
|
||||
</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 items-center gap-2">
|
||||
<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-1">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -267,7 +267,7 @@ export default function ResultsPage() {
|
||||
onClick={() => toggleExpanded(scheduleId)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>View Classes</span>
|
||||
<span>Select class</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
@@ -295,9 +295,9 @@ export default function ResultsPage() {
|
||||
disabled={!isAvailable}
|
||||
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary-50 dark:bg-primary-900/20 shadow-md'
|
||||
? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md'
|
||||
: isAvailable
|
||||
? 'border-gray-200 dark:border-gray-700 hover:border-primary-300 hover:shadow-sm'
|
||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm'
|
||||
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -2,14 +2,57 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
try {
|
||||
if (!token) {
|
||||
console.warn('No token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Invalid token format - expected 3 parts, got', parts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode JWT payload with proper base64 padding
|
||||
const payload = parts[1];
|
||||
const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = JSON.parse(atob(padded));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse base64:', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Decoded JWT payload keys:', Object.keys(decoded));
|
||||
console.log('passengerId from JWT:', decoded.passengerId);
|
||||
|
||||
if (!decoded.passengerId) {
|
||||
console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded));
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded.passengerId;
|
||||
} catch (error) {
|
||||
console.error('Error in getPassengerIdFromToken:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount } = useBookingStore();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore();
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const [timeLeft, setTimeLeft] = useState<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -62,7 +105,10 @@ export default function ReviewPage() {
|
||||
}, [selectedSchedule?.id, passengers]);
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/bookings/guest', data),
|
||||
mutationFn: (data: any) => {
|
||||
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
|
||||
return apiClient.post(endpoint, data);
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
console.log('Booking created successfully:', data);
|
||||
const bookingIdValue = data.bookingId || data.id;
|
||||
@@ -70,30 +116,25 @@ export default function ReviewPage() {
|
||||
|
||||
console.log('Setting booking ID:', bookingIdValue);
|
||||
console.log('Setting PNR:', pnrValue);
|
||||
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||
|
||||
setBookingId(bookingIdValue);
|
||||
setPNR(pnrValue);
|
||||
|
||||
// Check if payment is required
|
||||
const totalAmount = data.totalMinor || data.totalAmount || 0;
|
||||
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
|
||||
|
||||
console.log('Total amount:', totalAmount);
|
||||
console.log('Booking store after update:', useBookingStore.getState());
|
||||
|
||||
// Use setTimeout to ensure state updates complete before navigation
|
||||
setTimeout(() => {
|
||||
// Verify state was set
|
||||
const currentState = useBookingStore.getState();
|
||||
console.log('Current booking store state:', currentState);
|
||||
console.log('bookingId:', currentState.bookingId);
|
||||
console.log('pnr:', currentState.pnr);
|
||||
|
||||
if (totalAmount > 0) {
|
||||
// Redirect to payment page
|
||||
console.log('Redirecting to payment page');
|
||||
router.push('/booking/payment');
|
||||
} else {
|
||||
// No payment required, go directly to confirmation
|
||||
console.log('Redirecting to confirmation page');
|
||||
router.push('/booking/confirmation');
|
||||
}
|
||||
@@ -116,7 +157,6 @@ export default function ReviewPage() {
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
// Validate that we have a hold
|
||||
if (!seatHold?.holdId) {
|
||||
console.error('No seat hold found');
|
||||
alert('Please select seats before continuing.');
|
||||
@@ -124,7 +164,6 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate search criteria
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
alert('Missing search criteria. Please start over.');
|
||||
@@ -132,7 +171,6 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class ID
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
@@ -144,37 +182,106 @@ export default function ReviewPage() {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
const bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB' as const,
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
const hasNationalId = isEthiopian && p.nationalId;
|
||||
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: hasNationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
|
||||
idDocumentNumber: p.nationalId || undefined,
|
||||
passportNumber: !hasNationalId ? p.passportNumber : undefined,
|
||||
passportCountry: !hasNationalId ? p.passportCountry : undefined,
|
||||
nationality: p.nationality,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
let bookingData: any;
|
||||
if (isAuthenticated) {
|
||||
// For authenticated users: get passengerId from multiple sources
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
|
||||
if (!token) {
|
||||
console.error('No token in localStorage');
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
// Save deviceId for future use
|
||||
if (typeof window !== 'undefined' && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
console.log('Token found, length:', token.length);
|
||||
|
||||
let passengerId = getPassengerIdFromToken(token);
|
||||
console.log('Extracted passenger ID from JWT token:', passengerId);
|
||||
|
||||
// Fallback 1: Use passengerId from booking store
|
||||
if (!passengerId && storedPassengerId) {
|
||||
passengerId = storedPassengerId;
|
||||
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
|
||||
}
|
||||
|
||||
// Fallback 2: Use passengerId from localStorage
|
||||
if (!passengerId && typeof window !== 'undefined') {
|
||||
const localStoragePassengerId = localStorage.getItem('booking_passengerId');
|
||||
if (localStoragePassengerId) {
|
||||
passengerId = localStoragePassengerId;
|
||||
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: Use passengerId from user object
|
||||
if (!passengerId && user) {
|
||||
passengerId = (user as any).passengerId;
|
||||
console.log('Fallback 3: Using passengerId from user object:', passengerId);
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
console.error('Failed to extract passengerId');
|
||||
console.error('User object:', user);
|
||||
console.error('User object keys:', user ? Object.keys(user) : 'null');
|
||||
console.error('Stored passengerId from booking store:', storedPassengerId);
|
||||
if (typeof window !== 'undefined') {
|
||||
console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId'));
|
||||
}
|
||||
throw new Error('Passenger ID not found in authentication token. Please log in again.');
|
||||
}
|
||||
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengerId: passengerId,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// For guests: send full passenger details array
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
@@ -182,11 +289,10 @@ export default function ReviewPage() {
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert('An unexpected error occurred. Please try again.');
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Only redirect to search if we're not in the middle of creating a booking
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
@@ -200,14 +306,11 @@ export default function ReviewPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debug: Log selected schedule data
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Base fare adult:', selectedSchedule.baseFareAdult);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
// Calculate fare - use the fare from selected schedule or from fare breakdown
|
||||
const baseFare = passengers.reduce((sum, p, i) => {
|
||||
// Get the fare per passenger from the schedule
|
||||
const farePerPassenger = selectedSchedule.baseFareAdult ||
|
||||
(selectedSchedule as any).fareAdult ||
|
||||
(selectedSchedule as any).price ||
|
||||
@@ -215,8 +318,6 @@ export default function ReviewPage() {
|
||||
|
||||
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
|
||||
|
||||
// For now, charge all passengers the same fare
|
||||
// TODO: Implement proper age-based pricing when we have dateOfBirth
|
||||
return sum + farePerPassenger;
|
||||
}, 0);
|
||||
|
||||
@@ -227,8 +328,8 @@ export default function ReviewPage() {
|
||||
return (
|
||||
<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-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review Your Booking</h1>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
@@ -240,7 +341,7 @@ export default function ReviewPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<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="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
@@ -290,15 +391,15 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
<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">ETB {(total / 100).toFixed(2)}</span>
|
||||
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +413,7 @@ export default function ReviewPage() {
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating Booking...' : 'Confirm & Pay'}
|
||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
@@ -30,6 +31,8 @@ export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||
|
||||
const { data: stations, isLoading, error } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
@@ -49,7 +52,19 @@ export default function SearchPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Restore previous search values from URL params
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.nationality) {
|
||||
const normalized = user.nationality.toUpperCase().trim();
|
||||
if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') {
|
||||
setValue('nationality', 'DJIBOUTIAN');
|
||||
} else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') {
|
||||
setValue('nationality', 'ETHIOPIAN');
|
||||
} else {
|
||||
setValue('nationality', 'OTHER');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user?.nationality, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const origin = searchParams.get('origin');
|
||||
const destination = searchParams.get('destination');
|
||||
@@ -67,19 +82,9 @@ export default function SearchPage() {
|
||||
}, [searchParams, setValue]);
|
||||
|
||||
const originId = watch('originStationId');
|
||||
const destinationId = watch('destinationStationId');
|
||||
const adultCount = watch('adultCount');
|
||||
const childCount = watch('childCount');
|
||||
|
||||
const swapStations = () => {
|
||||
if (originId && destinationId) {
|
||||
const tempOrigin = originId;
|
||||
const tempDestination = destinationId;
|
||||
setValue('originStationId', tempDestination);
|
||||
setValue('destinationStationId', tempOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setSearchCriteria(data);
|
||||
const params = new URLSearchParams({
|
||||
@@ -117,11 +122,13 @@ export default function SearchPage() {
|
||||
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Search Section */}
|
||||
<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 */}
|
||||
<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 */}
|
||||
@@ -144,17 +151,19 @@ export default function SearchPage() {
|
||||
)}
|
||||
|
||||
<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">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label>
|
||||
{/* First Row: 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-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
|
||||
{...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 station</option>
|
||||
<option value="">Select departure</option>
|
||||
{stations?.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
@@ -165,25 +174,17 @@ export default function SearchPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={swapStations}
|
||||
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>
|
||||
{/* 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-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
|
||||
{...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 station</option>
|
||||
<option value="">Select arrival</option>
|
||||
{stations?.map((s) => (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="space-y-2 relative z-10">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Departure Date</label>
|
||||
{/* 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) => {
|
||||
@@ -207,98 +207,142 @@ export default function SearchPage() {
|
||||
setValue('departureDate', `${year}-${month}-${day}`);
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select departure date"
|
||||
placeholder="Select date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
|
||||
)}
|
||||
</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"><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">Passengers</label>
|
||||
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = adultCount || 1;
|
||||
if (current > 1) 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) <= 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"><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>
|
||||
<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>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">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>
|
||||
{/* Third Row: Search Button */}
|
||||
<div>
|
||||
<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">
|
||||
<Search className="w-5 h-5 text-white" />
|
||||
<span>Search Train</span>
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Popular Routes */}
|
||||
<div className="mt-12">
|
||||
<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">
|
||||
@@ -312,7 +356,7 @@ export default function SearchPage() {
|
||||
<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-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>
|
||||
<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 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>
|
||||
|
||||
@@ -6,10 +6,36 @@ import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
|
||||
// Separate component for seat button to prevent re-render issues
|
||||
const SeatButton = memo(({ seat, isSelected, onToggle }: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
|
||||
@@ -29,20 +55,10 @@ export default function SeatsPage() {
|
||||
enabled: !!selectedSchedule?.id,
|
||||
});
|
||||
|
||||
// Debug: Log the seat map data
|
||||
useEffect(() => {
|
||||
if (seatMapData) {
|
||||
console.log('Seat map data:', seatMapData);
|
||||
console.log('Is array?', Array.isArray(seatMapData));
|
||||
console.log('Has coaches?', (seatMapData as any)?.coaches);
|
||||
}
|
||||
}, [seatMapData]);
|
||||
|
||||
const holdMutation = useMutation({
|
||||
mutationFn: async (seatIds: string[]) => {
|
||||
// Create temporary passenger IDs for the hold
|
||||
const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({
|
||||
passengerId: `temp-${Date.now()}-${i}`, // Temporary ID for guest booking
|
||||
passengerId: `temp-${Date.now()}-${i}`,
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
|
||||
@@ -61,47 +77,21 @@ export default function SeatsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Extract coaches and seats from seat map data
|
||||
const coaches = (seatMapData as any)?.coaches || [];
|
||||
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
||||
|
||||
// Debug: Log coaches
|
||||
useEffect(() => {
|
||||
console.log('Coaches:', coaches);
|
||||
console.log('Selected seat class:', selectedSchedule?.selectedSeatClass);
|
||||
if (coaches.length > 0) {
|
||||
console.log('First coach structure:', coaches[0]);
|
||||
console.log('First coach seatClass:', coaches[0]?.seatClass);
|
||||
console.log('First coach coachClass:', coaches[0]?.coachClass);
|
||||
}
|
||||
const filteredCoaches = useMemo(() => {
|
||||
return selectedSchedule?.selectedSeatClass
|
||||
? coaches.filter((c: any) => {
|
||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||
})
|
||||
: coaches;
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
|
||||
// Filter coaches by selected seat class if available
|
||||
const filteredCoaches = selectedSchedule?.selectedSeatClass
|
||||
? coaches.filter((c: any) => {
|
||||
// seatClass can be either a string or an object with a name property
|
||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||
console.log('Comparing:', seatClassName, 'with', selectedSchedule.selectedSeatClass);
|
||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||
})
|
||||
: coaches;
|
||||
|
||||
// Debug filtered coaches
|
||||
useEffect(() => {
|
||||
console.log('Filtered coaches:', filteredCoaches);
|
||||
console.log('Filtered coaches count:', filteredCoaches.length);
|
||||
}, [filteredCoaches]);
|
||||
|
||||
const selectedCoachData = filteredCoaches.find((c: any) => c.id === selectedCoach);
|
||||
const seats = selectedCoachData?.seats || [];
|
||||
|
||||
// Debug seats
|
||||
useEffect(() => {
|
||||
console.log('Selected coach data:', selectedCoachData);
|
||||
console.log('Seats:', seats);
|
||||
console.log('Seats count:', seats.length);
|
||||
}, [selectedCoachData, seats]);
|
||||
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
|
||||
const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
||||
@@ -109,21 +99,28 @@ export default function SeatsPage() {
|
||||
}
|
||||
}, [filteredCoaches, selectedCoach]);
|
||||
|
||||
const toggleSeat = (seatId: string) => {
|
||||
if (selectedSeats.includes(seatId)) {
|
||||
setSelectedSeats(selectedSeats.filter(id => id !== seatId));
|
||||
} else if (selectedSeats.length < passengers.length) {
|
||||
setSelectedSeats([...selectedSeats, seatId]);
|
||||
}
|
||||
};
|
||||
const toggleSeat = useCallback((seatId: string) => {
|
||||
setSelectedSeats(prev => {
|
||||
if (prev.includes(seatId)) {
|
||||
return prev.filter(id => id !== seatId);
|
||||
} else if (prev.length < passengers.length) {
|
||||
return [...prev, seatId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [passengers.length]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (selectedSeats.length > 0) {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => ({
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
}));
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = seats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
}
|
||||
router.push('/booking/review');
|
||||
@@ -146,10 +143,14 @@ export default function SeatsPage() {
|
||||
|
||||
try {
|
||||
await holdMutation.mutateAsync(autoSelectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => ({
|
||||
...p,
|
||||
seatId: autoSelectedSeats[i],
|
||||
}));
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = availableSeats[i];
|
||||
return {
|
||||
...p,
|
||||
seatId: autoSelectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
router.push('/booking/review');
|
||||
} catch (error: any) {
|
||||
@@ -163,10 +164,13 @@ export default function SeatsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}, [selectedSchedule, passengers.length, router]);
|
||||
|
||||
if (!selectedSchedule || !passengers.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -180,12 +184,12 @@ export default function SeatsPage() {
|
||||
<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-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="lg:col-span-2">
|
||||
<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 && (
|
||||
<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>
|
||||
@@ -215,7 +219,7 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Loading seats...</p>
|
||||
@@ -228,35 +232,21 @@ export default function SeatsPage() {
|
||||
) : seats.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{/* Seat Grid */}
|
||||
<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))` }}>
|
||||
{seats?.map((seat: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||
return (
|
||||
<button
|
||||
key={seat.id}
|
||||
onClick={() => seat.status === 'AVAILABLE' && toggleSeat(seat.id)}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{seats?.map((seat: any) => (
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={selectedSeats.includes(seat.id)}
|
||||
onToggle={toggleSeat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +276,7 @@ export default function SeatsPage() {
|
||||
|
||||
<div>
|
||||
<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">
|
||||
Select {passengers.length} seat(s) for your passengers
|
||||
</p>
|
||||
@@ -314,14 +304,14 @@ export default function SeatsPage() {
|
||||
disabled={selectedSeats.length === 0}
|
||||
className="btn-primary w-full mb-2"
|
||||
>
|
||||
Continue with Selected Seats
|
||||
Continue with selected seats
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
disabled={holdMutation.isPending}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-Assign Seats'}
|
||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
375
apps/edr-passenger-web/portal/src/app/contact/page.tsx
Normal file
375
apps/edr-passenger-web/portal/src/app/contact/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,27 +10,47 @@
|
||||
|
||||
@layer components {
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@@ -62,4 +82,79 @@
|
||||
.text-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function HowToGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
You can sign in for a faster experience or continue as a guest.
|
||||
</p>
|
||||
@@ -113,7 +113,7 @@ export default function HowToGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
Choose your preferred seats from the interactive seat map. Available seats are shown in green.
|
||||
</p>
|
||||
|
||||
424
apps/edr-passenger-web/portal/src/app/help/page.tsx
Normal file
424
apps/edr-passenger-web/portal/src/app/help/page.tsx
Normal 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 "{searchTerm}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -15,7 +17,7 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@@ -39,8 +41,12 @@ export default function RootLayout({
|
||||
}}
|
||||
/>
|
||||
<Providers>
|
||||
<LoadingIndicator />
|
||||
<AppHeader />
|
||||
{children}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { Train } from 'lucide-react';
|
||||
|
||||
@@ -20,7 +19,6 @@ function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const { searchCriteria } = useBookingStore();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -43,14 +41,16 @@ function LoginContent() {
|
||||
};
|
||||
|
||||
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="text-center mb-8">
|
||||
<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>
|
||||
<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>
|
||||
<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</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -88,18 +88,13 @@ function LoginContent() {
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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);
|
||||
}}
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
>
|
||||
← Back to booking
|
||||
@@ -113,10 +108,12 @@ function LoginContent() {
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<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">
|
||||
<Suspense fallback={
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,438 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/booking/search');
|
||||
import { useEffect, useState } from 'react';
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,9 +65,7 @@ export default function ProfilePage() {
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (isAuthenticated && user) {
|
||||
// Fetch fresh profile data
|
||||
fetchProfile().catch(() => {
|
||||
// If fetch fails, redirect to login
|
||||
router.push('/login?redirect=/profile');
|
||||
});
|
||||
|
||||
@@ -200,7 +198,6 @@ export default function ProfilePage() {
|
||||
message: 'Are you sure you want to sign out?',
|
||||
onConfirm: async () => {
|
||||
await logout();
|
||||
// Navigation will be handled by logout function
|
||||
},
|
||||
});
|
||||
setShowModal(true);
|
||||
@@ -245,7 +242,7 @@ export default function ProfilePage() {
|
||||
if (!isInitialized || !user) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -258,7 +255,7 @@ export default function ProfilePage() {
|
||||
<div className="card mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -289,7 +286,7 @@ export default function ProfilePage() {
|
||||
onClick={() => setActiveTab('bookings')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -300,7 +297,7 @@ export default function ProfilePage() {
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -311,7 +308,7 @@ export default function ProfilePage() {
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -328,7 +325,7 @@ export default function ProfilePage() {
|
||||
|
||||
{loadingBookings ? (
|
||||
<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>
|
||||
</div>
|
||||
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
|
||||
@@ -482,7 +479,7 @@ export default function ProfilePage() {
|
||||
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
|
||||
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>
|
||||
</label>
|
||||
@@ -495,7 +492,7 @@ export default function ProfilePage() {
|
||||
onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })}
|
||||
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>
|
||||
</label>
|
||||
@@ -508,7 +505,7 @@ export default function ProfilePage() {
|
||||
onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })}
|
||||
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>
|
||||
</label>
|
||||
|
||||
309
apps/edr-passenger-web/portal/src/app/services/page.tsx
Normal file
309
apps/edr-passenger-web/portal/src/app/services/page.tsx
Normal 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 (<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { Train, User, BookOpen, LogIn } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
export default function AppHeader() {
|
||||
const { user, isAuthenticated, initialize } = useAuthStore();
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
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>
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
setIsDark(isDarkMode);
|
||||
}, []);
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isAuthenticated ? (
|
||||
<Link
|
||||
href="/login"
|
||||
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="Sign In"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
</Link>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||
{user?.fullName}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/guide"
|
||||
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"
|
||||
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 isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</header>
|
||||
|
||||
165
apps/edr-passenger-web/portal/src/components/Footer.tsx
Normal file
165
apps/edr-passenger-web/portal/src/components/Footer.tsx
Normal 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>
|
||||
© 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>
|
||||
);
|
||||
}
|
||||
163
apps/edr-passenger-web/portal/src/components/LandingNav.tsx
Normal file
163
apps/edr-passenger-web/portal/src/components/LandingNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const steps: Step[] = [
|
||||
{ id: 'seats', name: 'Seats', href: '/booking/seats' },
|
||||
{ id: 'review', name: 'Review', href: '/booking/review' },
|
||||
{ id: 'payment', name: 'Payment', href: '/booking/payment' },
|
||||
{ id: 'confirmation', name: 'Done', href: '/booking/confirmation' },
|
||||
{ id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' },
|
||||
];
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
@@ -27,7 +27,7 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const isComplete = index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
|
||||
279
apps/edr-passenger-web/portal/src/components/SearchWidget.tsx
Normal file
279
apps/edr-passenger-web/portal/src/components/SearchWidget.tsx
Normal 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"><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>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,22 @@ interface User {
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
passengerId?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
nationalityCode?: string;
|
||||
nationalId?: string;
|
||||
passportNumber?: string;
|
||||
passportCountry?: string;
|
||||
passportIssueDate?: string;
|
||||
passportExpiryDate?: string;
|
||||
passportIssuingAuthority?: string;
|
||||
faydaVerified?: boolean;
|
||||
faydaSub?: string;
|
||||
faydaVerifiedAt?: string;
|
||||
lastLoginAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PassengerDetail {
|
||||
idDocumentType?: string;
|
||||
isPrimaryPassenger: boolean;
|
||||
seatId?: string;
|
||||
seatNumber?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
gender?: string;
|
||||
@@ -58,6 +59,7 @@ interface BookingState {
|
||||
pnr: string | null;
|
||||
selectedPaymentMethod: string | null;
|
||||
createAccount: boolean;
|
||||
passengerId: string | null;
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
@@ -67,11 +69,12 @@ interface BookingState {
|
||||
setPNR: (pnr: string) => void;
|
||||
setPaymentMethod: (method: string) => void;
|
||||
setCreateAccount: (create: boolean) => void;
|
||||
setPassengerId: (id: string | null) => void;
|
||||
clearBooking: () => void;
|
||||
}
|
||||
|
||||
export const useBookingStore = create<BookingState>()(persist(
|
||||
(set) => ({
|
||||
(set) => (({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
passengers: [],
|
||||
@@ -80,6 +83,7 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
pnr: null,
|
||||
selectedPaymentMethod: null,
|
||||
createAccount: false,
|
||||
passengerId: null,
|
||||
|
||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||
@@ -89,6 +93,7 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
setPNR: (pnr) => set({ pnr }),
|
||||
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
|
||||
setCreateAccount: (create) => set({ createAccount: create }),
|
||||
setPassengerId: (id) => set({ passengerId: id }),
|
||||
clearBooking: () => set({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
@@ -98,8 +103,9 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
pnr: null,
|
||||
selectedPaymentMethod: null,
|
||||
createAccount: false,
|
||||
passengerId: null,
|
||||
}),
|
||||
}),
|
||||
} as BookingState)),
|
||||
{
|
||||
name: 'booking-storage',
|
||||
storage: createJSONStorage(() => {
|
||||
|
||||
419
apps/edr-passenger-web/portal/src/lib/i18n.ts
Normal file
419
apps/edr-passenger-web/portal/src/lib/i18n.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { format, toZonedTime } from 'date-fns-tz';
|
||||
|
||||
const ADDIS_TZ = 'Africa/Addis_Ababa';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export const formatCurrency = (amount: number, currency: string = 'ETB'): string => {
|
||||
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 => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, formatStr, { timeZone: ADDIS_TZ });
|
||||
return format(new Date(date), formatStr);
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: string | Date): string => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, 'MMM dd, yyyy HH:mm', { timeZone: ADDIS_TZ });
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const formatTime = (date: string | Date): string => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, 'HH:mm', { timeZone: ADDIS_TZ });
|
||||
return format(new Date(date), 'HH:mm');
|
||||
};
|
||||
|
||||
@@ -1,61 +1,88 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
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',
|
||||
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: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
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',
|
||||
},
|
||||
primary: 'rgb(20 113 76)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
],
|
||||
backgroundColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
textColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
},
|
||||
borderColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
},
|
||||
ringColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.4s ease-out',
|
||||
'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'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: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
'bounce-in': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(20px) scale(0.9)',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '1',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
'float': {
|
||||
'0%, 100%': {
|
||||
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: [],
|
||||
darkMode: 'class',
|
||||
};
|
||||
|
||||
293
pnpm-lock.yaml
generated
293
pnpm-lock.yaml
generated
@@ -153,9 +153,6 @@ importers:
|
||||
'@nestjs/jwt':
|
||||
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))
|
||||
'@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':
|
||||
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)
|
||||
@@ -201,15 +198,12 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
express:
|
||||
specifier: ^4.18.2
|
||||
version: 4.22.2
|
||||
jose:
|
||||
specifier: ^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:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
@@ -224,7 +218,7 @@ importers:
|
||||
version: 7.8.2
|
||||
swagger-ui-express:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.1(express@5.2.1)
|
||||
version: 5.0.1(express@4.22.2)
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@@ -250,15 +244,15 @@ importers:
|
||||
'@types/bcrypt':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.25
|
||||
'@types/jest':
|
||||
specifier: ^29.5.11
|
||||
version: 29.5.14
|
||||
'@types/node':
|
||||
specifier: ^20.10.6
|
||||
version: 20.19.41
|
||||
'@types/passport-jwt':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.6
|
||||
@@ -1765,9 +1759,15 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
@@ -1795,9 +1795,6 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
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':
|
||||
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
|
||||
|
||||
@@ -1807,8 +1804,8 @@ packages:
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
@@ -1819,15 +1816,6 @@ packages:
|
||||
'@types/node@25.9.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -1848,9 +1836,15 @@ packages:
|
||||
'@types/react@18.3.29':
|
||||
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':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
'@types/serve-static@1.15.10':
|
||||
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
@@ -2115,6 +2109,10 @@ packages:
|
||||
abbrev@1.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2417,6 +2415,9 @@ packages:
|
||||
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
|
||||
array-ify@1.0.0:
|
||||
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
|
||||
|
||||
@@ -2583,6 +2584,10 @@ packages:
|
||||
bluebird@3.4.7:
|
||||
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:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2886,6 +2891,10 @@ packages:
|
||||
console-control-strings@1.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2914,6 +2923,9 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-signature@1.0.7:
|
||||
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
@@ -3182,6 +3194,10 @@ packages:
|
||||
destr@2.0.5:
|
||||
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:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3567,6 +3583,10 @@ packages:
|
||||
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3664,6 +3684,10 @@ packages:
|
||||
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
finalhandler@1.3.2:
|
||||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
@@ -3746,6 +3770,10 @@ packages:
|
||||
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4020,6 +4048,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4899,6 +4931,9 @@ packages:
|
||||
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
|
||||
engines: {node: '>=16.10'}
|
||||
|
||||
merge-descriptors@1.0.3:
|
||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4938,6 +4973,11 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -5051,6 +5091,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
negotiator@0.6.3:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5328,6 +5372,9 @@ packages:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
path-to-regexp@0.1.13:
|
||||
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
|
||||
|
||||
path-to-regexp@3.3.0:
|
||||
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
|
||||
|
||||
@@ -5581,6 +5628,10 @@ packages:
|
||||
rapiq@0.9.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -5835,10 +5886,18 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@0.19.2:
|
||||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@1.16.3:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -8381,7 +8440,7 @@ snapshots:
|
||||
|
||||
'@types/conventional-commits-parser@5.0.2':
|
||||
dependencies:
|
||||
'@types/node': 25.9.1
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
@@ -8421,6 +8480,13 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
@@ -8428,6 +8494,13 @@ snapshots:
|
||||
'@types/range-parser': 1.2.7
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
@@ -8459,11 +8532,6 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
@@ -8472,7 +8540,7 @@ snapshots:
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
@@ -8484,20 +8552,6 @@ snapshots:
|
||||
dependencies:
|
||||
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/qrcode@1.5.6':
|
||||
@@ -8517,10 +8571,21 @@ snapshots:
|
||||
'@types/prop-types': 15.7.15
|
||||
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':
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
@@ -8801,6 +8866,11 @@ snapshots:
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -9129,6 +9199,8 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
array-flatten@1.1.1: {}
|
||||
|
||||
array-ify@1.0.0: {}
|
||||
|
||||
array-includes@3.1.9:
|
||||
@@ -9364,6 +9436,23 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -9693,6 +9782,10 @@ snapshots:
|
||||
|
||||
console-control-strings@1.1.0: {}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
@@ -9716,6 +9809,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-signature@1.0.7: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
@@ -9947,6 +10042,8 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-newline@3.1.0: {}
|
||||
@@ -10477,6 +10574,42 @@ snapshots:
|
||||
jest-message-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:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -10611,6 +10744,18 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -10706,6 +10851,8 @@ snapshots:
|
||||
dependencies:
|
||||
map-cache: 0.2.2
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
@@ -11038,6 +11185,10 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -12075,6 +12226,8 @@ snapshots:
|
||||
|
||||
meow@12.1.1: {}
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
@@ -12118,6 +12271,8 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
@@ -12234,6 +12389,8 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
@@ -12532,6 +12689,8 @@ snapshots:
|
||||
lru-cache: 11.5.0
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@0.1.13: {}
|
||||
|
||||
path-to-regexp@3.3.0: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
@@ -12742,6 +12901,13 @@ snapshots:
|
||||
ebec: 1.1.1
|
||||
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:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -13020,6 +13186,24 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -13036,6 +13220,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
@@ -13407,9 +13600,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@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:
|
||||
express: 5.2.1
|
||||
express: 4.22.2
|
||||
swagger-ui-dist: 5.32.6
|
||||
|
||||
symbol-observable@4.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user