mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #90 from Tria-plc/alpha
Booking and ticketing related updates
This commit is contained in:
@@ -437,6 +437,7 @@ model Seat {
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
bookingSeats BookingSeat[]
|
||||
blocks SeatBlock[]
|
||||
ticketSeats TicketSeat[]
|
||||
@@unique([coachId, row, col])
|
||||
@@unique([coachId, seatNumber])
|
||||
|
||||
@@ -627,6 +628,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")
|
||||
}
|
||||
|
||||
@@ -236,6 +236,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();
|
||||
|
||||
@@ -106,14 +106,27 @@ export class BookingsService {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
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) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
where.AND = [{ OR: searchConditions }];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
@@ -135,13 +148,8 @@ export class BookingsService {
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
const savedPassengers = await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { deviceId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return {
|
||||
bookings: items.map(booking => ({
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
@@ -162,17 +170,6 @@ export class BookingsService {
|
||||
paymentIntent: booking.paymentIntent,
|
||||
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: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -298,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;
|
||||
|
||||
@@ -250,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: {
|
||||
|
||||
@@ -182,8 +182,6 @@ export class PaymentsService {
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private formatIntentResponse(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): InitiateResponseDto {
|
||||
@@ -350,9 +348,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 };
|
||||
}
|
||||
@@ -391,4 +411,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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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);
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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,
|
||||
@@ -27,6 +45,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details',
|
||||
description: `Returns ticket information including:
|
||||
@@ -42,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.'
|
||||
@@ -55,27 +77,35 @@ 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'
|
||||
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';
|
||||
|
||||
@@ -19,6 +19,7 @@ export class TicketsService {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
@@ -44,7 +45,13 @@ export class TicketsService {
|
||||
items: tickets.map((t) => ({
|
||||
id: t.id,
|
||||
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,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
status: t.booking.status,
|
||||
@@ -58,18 +65,90 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -197,6 +276,14 @@ export class TicketsService {
|
||||
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 };
|
||||
|
||||
@@ -19,13 +19,9 @@ export default function TicketsPage() {
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
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({
|
||||
mutationFn: ticketsApi.regenerate,
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -167,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;
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function AuthCheckPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary w-full">
|
||||
<button className="btn-primary w-full text-center">
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ export default function AuthCheckPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary w-full">
|
||||
<button className="btn-secondary w-full text-center">
|
||||
Continue as guest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,10 @@ export default function ConfirmationPage() {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
confirmMutation.mutate();
|
||||
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
});
|
||||
}
|
||||
}, [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>
|
||||
<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,
|
||||
@@ -204,7 +209,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>
|
||||
|
||||
|
||||
@@ -113,10 +113,14 @@ export default function SeatsPage() {
|
||||
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');
|
||||
@@ -139,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) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
@layer components {
|
||||
.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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PassengerDetail {
|
||||
idDocumentType?: string;
|
||||
isPrimaryPassenger: boolean;
|
||||
seatId?: string;
|
||||
seatNumber?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
gender?: string;
|
||||
|
||||
Reference in New Issue
Block a user