Booking and ticketing related updates

This commit is contained in:
Stephanos A
2026-06-04 16:21:22 +03:00
parent 681825f36c
commit bc4d6d079b
16 changed files with 284 additions and 64 deletions

View File

@@ -437,6 +437,7 @@ model Seat {
coach Coach @relation(fields: [coachId], references: [id]) coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[] bookingSeats BookingSeat[]
blocks SeatBlock[] blocks SeatBlock[]
ticketSeats TicketSeat[]
@@unique([coachId, row, col]) @@unique([coachId, row, col])
@@unique([coachId, seatNumber]) @@unique([coachId, seatNumber])
@@ -627,6 +628,20 @@ model Ticket {
validatorId String? validatorId String?
booking Booking @relation(fields: [bookingId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[] validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}
model TicketSeat {
id String @id @default(uuid())
ticketId String
seatId String
seatIndex Int @default(0)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
seat Seat @relation(fields: [seatId], references: [id])
@@index([ticketId])
@@index([seatId])
@@schema("passenger") @@schema("passenger")
} }

View File

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

View File

@@ -106,14 +106,27 @@ export class BookingsService {
const { search, status, page = 1, pageSize = 20 } = filters; const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: any = { passenger: { user: { devices: { some: { id: deviceId } } } } }; // 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) { if (search) {
where.OR = [ where.AND = [{ OR: searchConditions }];
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
} }
if (status) { if (status) {
@@ -135,13 +148,8 @@ export class BookingsService {
this.prisma.booking.count({ where }), this.prisma.booking.count({ where }),
]); ]);
const savedPassengers = await this.prisma.savedPassengerProfile.findMany({
where: { deviceId },
orderBy: { createdAt: 'desc' },
});
return { return {
bookings: items.map(booking => ({ items: items.map(booking => ({
id: booking.id, id: booking.id,
bookingRef: booking.bookingRef, bookingRef: booking.bookingRef,
status: booking.status, status: booking.status,
@@ -162,17 +170,6 @@ export class BookingsService {
paymentIntent: booking.paymentIntent, paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length, seatCount: booking.seats.length,
})), })),
savedPassengers: savedPassengers.map(p => ({
id: p.id,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber || undefined,
passportCountry: p.passportCountry || undefined,
nationality: p.nationality || undefined,
phone: p.phone || undefined,
email: p.email || undefined,
})),
meta: { meta: {
page, page,
pageSize, pageSize,
@@ -298,7 +295,6 @@ export class BookingsService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
} }
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
const primaryNationality = passengersData[0]?.nationality; const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount; const adultFareMinor = baseFareMinor * adultCount;

View File

@@ -250,6 +250,7 @@ export class GuestBookingService {
displayCurrency, displayCurrency,
displayTotalMinor, displayTotalMinor,
bookingType: 'ONE_WAY', bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration // contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration // contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: { seats: {

View File

@@ -182,8 +182,6 @@ export class PaymentsService {
return this.formatIntentResponse(intent); return this.formatIntentResponse(intent);
} }
private formatIntentResponse( private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>, intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): InitiateResponseDto { ): InitiateResponseDto {
@@ -349,9 +347,31 @@ export class PaymentsService {
}); });
}); });
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); try {
await this.ticketsService.generate(booking.id); await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); } catch (err) {
this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await this.createJourneySegments(booking);
} catch (err) {
this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await this.ticketsService.generate(booking.id);
} catch (err) {
this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
try {
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
} catch (err) {
this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`);
}
this.eventEmitter.emit('payment.succeeded', { booking }); this.eventEmitter.emit('payment.succeeded', { booking });
return { alreadyFinalized: false }; return { alreadyFinalized: false };
} }
@@ -390,4 +410,47 @@ export class PaymentsService {
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
} }
private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: booking.scheduleId },
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!schedule) return;
const stopTimes = schedule.stopTimes;
if (stopTimes.length < 2) return;
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency,
},
});
const journeySegments = [];
for (const bookingSeat of booking.seats) {
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: booking.scheduleId,
segmentOrder: i,
seatId: bookingSeat.seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
if (journeySegments.length > 0) {
await this.prisma.journeySegment.createMany({ data: journeySegments });
}
}
} }

View File

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

View File

@@ -1,16 +1,34 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Tickets') @ApiTags('Tickets')
@Controller('tickets') @Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController { export class TicketsController {
constructor(private service: TicketsService) {} constructor(private service: TicketsService) {}
@Post('generate/:bookingId')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
})
generateTicket(@Param('bookingId') bookingId: string) {
return this.service.generate(bookingId);
}
@Patch('update-seats/:bookingId')
@ApiOperation({
summary: 'Update ticket seats before final confirmation',
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
})
updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) {
return this.service.updateSeats(bookingId, body.seatIds);
}
@Get() @Get()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiOperation({ summary: 'List all tickets with optional filters' })
listTickets( listTickets(
@Query('search') search?: string, @Query('search') search?: string,
@@ -27,6 +45,8 @@ export class TicketsController {
} }
@Get(':bookingRef') @Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Get ticket with QR code and passenger details', summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including: description: `Returns ticket information including:
@@ -42,6 +62,8 @@ export class TicketsController {
} }
@Post(':bookingRef/validate') @Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Validate ticket at gate with audit logging', summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.' description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
@@ -55,27 +77,35 @@ export class TicketsController {
} }
@Get(':ticketId/validation-logs') @Get(':ticketId/validation-logs')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' }) @ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) { getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId); return this.service.getValidationLogs(ticketId);
} }
@Get('offline/export') @Get('offline/export')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' }) @ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) { exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId); return this.service.exportOfflineData(scheduleId);
} }
@Post('validate/offline') @Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' }) @ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) { validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations); return this.service.validateOfflineBatch(body.validations);
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Delete ticket (admin only)', summary: 'Delete ticket (admin only)',
description: 'Permanently deletes a ticket record' description: 'Permanently deletes a ticket record and removes associated seat blocks'
}) })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {
return this.service.delete(id); return this.service.delete(id);

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller'; import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] }) @Module({
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],
})
export class TicketsModule {} export class TicketsModule {}
export { TicketsController } from './tickets.controller';

View File

@@ -19,6 +19,7 @@ export class TicketsService {
where.OR = [ where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } }, { bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } }, { barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
]; ];
} }
if (filters.status) { if (filters.status) {
@@ -44,7 +45,13 @@ export class TicketsService {
items: tickets.map((t) => ({ items: tickets.map((t) => ({
id: t.id, id: t.id,
ticketNumber: t.barcodePayload, ticketNumber: t.barcodePayload,
booking: t.booking, 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, schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat, seat: t.booking.seats[0]?.seat,
status: t.booking.status, status: t.booking.status,
@@ -58,18 +65,90 @@ export class TicketsService {
} }
async generate(bookingId: string) { async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({ const booking = await this.prisma.booking.findUnique({
where: { id: bookingId }, where: { id: bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } }, include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
},
}); });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId }, const ticket = await this.prisma.ticket.upsert({
update: { qrPayload, barcodePayload }, where: { bookingId },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload } update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
}); });
// Create permanent seat blocks for all booked seats
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
}
return ticket;
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
});
}
// Remove old booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
passengerName: `Passenger ${i + 1}`,
}
});
}
return { success: true, updatedSeats: newSeatIds.length };
} }
async getByRef(bookingRef: string) { async getByRef(bookingRef: string) {
@@ -197,6 +276,14 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found'); if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } }); 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 } }); await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id }; return { deleted: true, ticketId: id };

View File

@@ -19,13 +19,9 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters], queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll(filters), queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
}); });
if (error) {
console.error('Tickets API Error:', error);
}
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate, mutationFn: ticketsApi.regenerate,
onSuccess: () => { onSuccess: () => {

View File

@@ -167,7 +167,10 @@ export const paymentsApi = {
// Tickets API // Tickets API
export const ticketsApi = { export const ticketsApi = {
getAll: async (params?: any) => { getAll: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString(); const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`); const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`);
if (response?.data) { if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response; return Array.isArray(response.data) ? { items: response.data } : response;

View File

@@ -77,7 +77,7 @@ export default function AuthCheckPage() {
</div> </div>
</div> </div>
<button className="btn-primary w-full"> <button className="btn-primary w-full text-center">
Sign in to continue Sign in to continue
</button> </button>
</div> </div>
@@ -119,7 +119,7 @@ export default function AuthCheckPage() {
</div> </div>
</div> </div>
<button className="btn-secondary w-full"> <button className="btn-secondary w-full text-center">
Continue as guest Continue as guest
</button> </button>
</div> </div>

View File

@@ -43,6 +43,10 @@ export default function ConfirmationPage() {
if (bookingId && !confirmAttempted.current) { if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true; confirmAttempted.current = true;
confirmMutation.mutate(); confirmMutation.mutate();
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
console.error('Failed to generate ticket:', err);
});
} }
}, [bookingId, confirmMutation]); }, [bookingId, confirmMutation]);
@@ -167,8 +171,9 @@ export default function ConfirmationPage() {
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2> <h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
<div className="space-y-4"> <div className="space-y-4">
{passengers.map((passenger, index) => { {passengers.map((passenger, index) => {
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; const backendTicket = _booking?.ticket || null;
const qrData = JSON.stringify({ const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
const qrData = backendTicket?.qrPayload || JSON.stringify({
pnr, pnr,
ticketNumber, ticketNumber,
passengerName: passenger.name, passengerName: passenger.name,
@@ -204,7 +209,7 @@ export default function ConfirmationPage() {
</div> </div>
<div> <div>
<p className="text-gray-600 dark:text-gray-400">Seat</p> <p className="text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p> <p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatNumber || 'Will be assigned'}</p>
</div> </div>
</div> </div>

View File

@@ -113,10 +113,14 @@ export default function SeatsPage() {
const handleContinue = async () => { const handleContinue = async () => {
if (selectedSeats.length > 0) { if (selectedSeats.length > 0) {
await holdMutation.mutateAsync(selectedSeats); await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => ({ const updatedPassengers = passengers.map((p, i) => {
...p, const seatData = seats?.find((s: any) => s.id === selectedSeats[i]);
seatId: selectedSeats[i], return {
})); ...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
} }
router.push('/booking/review'); router.push('/booking/review');
@@ -139,10 +143,14 @@ export default function SeatsPage() {
try { try {
await holdMutation.mutateAsync(autoSelectedSeats); await holdMutation.mutateAsync(autoSelectedSeats);
const updatedPassengers = passengers.map((p, i) => ({ const updatedPassengers = passengers.map((p, i) => {
...p, const seatData = availableSeats[i];
seatId: autoSelectedSeats[i], return {
})); ...p,
seatId: autoSelectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
router.push('/booking/review'); router.push('/booking/review');
} catch (error: any) { } catch (error: any) {

View File

@@ -10,7 +10,7 @@
@layer components { @layer components {
.btn-primary { .btn-primary {
@apply inline-flex items-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; @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 { .btn-primary:hover {

View File

@@ -25,6 +25,7 @@ export interface PassengerDetail {
idDocumentType?: string; idDocumentType?: string;
isPrimaryPassenger: boolean; isPrimaryPassenger: boolean;
seatId?: string; seatId?: string;
seatNumber?: string;
phone?: string; phone?: string;
email?: string; email?: string;
gender?: string; gender?: string;