fix conflict

This commit is contained in:
yaschalew
2026-06-24 10:08:19 +03:00
17 changed files with 1425 additions and 213 deletions

View File

@@ -13,7 +13,7 @@ permissions:
jobs:
detect-changes:
name: Detect changed services
runs-on: self-hosted
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
@@ -52,7 +52,7 @@ jobs:
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
@@ -91,7 +91,7 @@ jobs:
name: Deploy ${{ matrix.service }}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: self-hosted
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
strategy:
fail-fast: false
matrix:

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ coverage/
.idea/
.vscode/
.npmrc
# emacs cache files
*~
\#*\#
.\#*

View File

@@ -272,8 +272,11 @@ export const api = {
getAvailableDays: endpoint<
{ originYardId?: string; destinationYardId?: string },
string[]
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
>(
"train-scheduling",
"availableDays",
({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
},

View File

@@ -412,6 +412,17 @@ export class BookingsController {
return this.service.create(dto);
}
@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);
}
@Get(':bookingRef')
@ApiOperation({
summary: 'Get booking details by reference (no auth required)',
@@ -423,17 +434,6 @@ 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')
@@ -458,17 +458,17 @@ export class BookingsController {
return this.service.delete(id);
}
@Get(':id/usage')
@Patch(':id')
@ApiOperation({
summary: 'Check if booking is in use',
description: 'Returns list of modules/data that reference this booking'
summary: 'Update booking details',
description: 'Updates booking information for admin/agent operations'
})
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
checkUsage(@Param('id') id: string) {
return this.service.checkBookingUsage(id);
update(@Param('id') id: string, @Body() dto: any) {
return this.service.update(id, dto);
}
@Delete(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -956,31 +956,47 @@ export class BookingsService {
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, ticket: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
totalMinor: booking.totalMinor, currency: 'ETB',
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
id: booking.schedule.id,
trainNumber: booking.schedule.train.number,
trainName: booking.schedule.train.name,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
},
passengers: booking.seats?.map((bs: any) => ({
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
fullName: bs.passengerName,
category: bs.passengerCategory,
leg: bs.leg ?? 1,
fareMinor: bs.fareMinor,
verifaydaVerified: bs.verifaydaVerified,
seat: {
id: bs.seat.id,
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
coachId: bs.seat.coach.id,
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
},
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
};
}

View File

@@ -190,9 +190,9 @@ 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
userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
@@ -384,6 +384,8 @@ export class GuestBookingService {
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map((p) => ({
@@ -574,6 +576,8 @@ export class GuestBookingService {
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId: leg2SeatClassId,
userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map(p => ({
@@ -785,6 +789,8 @@ export class GuestBookingService {
returnLeg2SeatClassId: retL2ClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
@@ -859,13 +865,16 @@ export class GuestBookingService {
return { guestPassenger, userId: user.id, createdAccount: true };
}
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
let guestEmail = firstPassenger.email;
if (guestEmail) {
const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } });
if (existing) guestEmail = null;
}
let guestPhone = firstPassenger.phone || null;
if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`;
let guestPhone = firstPassenger.phone;
if (guestPhone) {
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existing) guestPhone = null;

View File

@@ -93,10 +93,20 @@ export class TicketsService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
paymentIntent: true,
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.status !== 'CONFIRMED') {
const paymentStatus = booking.paymentIntent?.status ?? null;
throw new BadRequestException(
`Payment not completed. Please complete your payment before accessing the ticket. ` +
`Booking status: ${booking.status}` +
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
);
}
// Build a compact multi-leg payload for the QR so gate scanners see all legs
const legSummary = this.buildLegSummary(booking);
const qrData = JSON.stringify({

View File

@@ -13,13 +13,17 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@hookform/resolvers": "^3.3.4",
"@tanstack/react-query": "^5.59.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^3.0.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.446.0",
"next": "^14.2.0",
"qrcode": "^1.5.4",
"qrcode.react": "^3.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store';
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns';
@@ -26,6 +26,7 @@ export default function ConfirmationPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(false);
const confirmMutation = useMutation({
@@ -69,8 +70,61 @@ export default function ConfirmationPage() {
}
};
const handleDownloadTickets = () => {
alert('Ticket download will be available soon. Your tickets are displayed below.');
const handleDownloadVoucher = async () => {
if (!_booking || !pnr) {
alert('Booking data not available. Please try again.');
return;
}
setIsGeneratingVoucher(true);
try {
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
const voucherData = {
bookingRef: pnr,
status: _booking.status || 'CONFIRMED',
passengers: passengers.map(p => ({
fullName: p.name,
category: 'ADULT',
seat: p.seatNumber ? {
number: p.seatNumber,
coach: 'N/A',
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
} : undefined,
})),
schedule: {
trainNumber: selectedSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express',
origin: {
name: selectedSchedule?.origin || 'Origin',
code: 'ORG',
city: selectedSchedule?.origin || 'Origin',
},
destination: {
name: selectedSchedule?.destination || 'Destination',
code: 'DST',
city: selectedSchedule?.destination || 'Destination',
},
departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
},
totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
currency: 'ETB',
bookingType: 'ONE_WAY',
createdAt: new Date().toISOString(),
};
console.log('📄 Voucher data prepared:', voucherData);
await generateVoucherPDF(voucherData);
console.log('✅ Voucher generated successfully');
} catch (error) {
console.error('❌ Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsGeneratingVoucher(false);
}
};
const handlePrintTickets = () => {
@@ -131,47 +185,63 @@ export default function ConfirmationPage() {
</div>
</div>
{/* Trip Summary */}
{/* Trip Summary with QR Code */}
<div className="card mb-6">
<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">
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
<div className="flex flex-col md:flex-row gap-6">
{/* QR Code Section */}
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
<QRCodeSVG
value={pnr}
size={160}
level="H"
includeMargin={true}
/>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
</div>
<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="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</p>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
{/* Trip Details */}
<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">
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')}
</p>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip details</h2>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule?.duration}</p>
<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="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</p>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule?.duration}</p>
</div>
</div>
</div>
</div>
</div>
@@ -184,62 +254,36 @@ export default function ConfirmationPage() {
{passengers.map((passenger, index) => {
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,
trainNumber: selectedSchedule?.trainNumber,
date: selectedSchedule?.departureTime,
});
return (
<div key={index} className="card hover:shadow-lg transition-shadow">
<div className="flex flex-col md:flex-row gap-6">
{/* Ticket Info */}
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
</div>
<span className="badge badge-success">CONFIRMED</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
</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.seatNumber || 'Will be assigned'}</p>
</div>
</div>
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
📱 Show this QR code at the gate for boarding
</p>
{/* Ticket Info */}
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
</div>
<span className="badge badge-success">CONFIRMED</span>
</div>
{/* QR Code */}
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<QRCodeSVG
value={qrData}
size={160}
level="H"
includeMargin={true}
/>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center">Scan at gate</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
</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.seatNumber || 'Will be assigned'}</p>
</div>
</div>
</div>
</div>
@@ -249,13 +293,24 @@ export default function ConfirmationPage() {
</div>
{/* Action Buttons */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
<button
onClick={handleDownloadTickets}
className="btn-secondary flex items-center justify-center gap-2"
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary flex items-center justify-center gap-2 relative"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
{isGeneratingVoucher ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span className="hidden sm:inline">Generating...</span>
</>
) : (
<>
<FileText className="w-4 h-4" />
<span className="hidden sm:inline">Download Voucher</span>
<span className="sm:hidden">Voucher</span>
</>
)}
</button>
<button
onClick={handlePrintTickets}
@@ -271,6 +326,13 @@ export default function ConfirmationPage() {
<Mail className="w-4 h-4" />
<span className="hidden sm:inline">Email</span>
</button>
<button
onClick={() => alert('Tickets download will be available soon.')}
className="btn-secondary flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</button>
<button className="btn-secondary flex items-center justify-center gap-2">
<Share2 className="w-4 h-4" />
<span className="hidden sm:inline">Share</span>

View File

@@ -0,0 +1,645 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import {
Clock,
Users,
CheckCircle2,
AlertCircle,
Download,
Share2,
Copy,
Check,
CreditCard,
Wallet
} from 'lucide-react';
import { format } from 'date-fns';
import QRCode from 'qrcode.react';
function BookingDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr');
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const { data: booking, isLoading, error, refetch } = useQuery({
queryKey: ['booking-detail', bookingRef],
queryFn: async () => {
if (!bookingRef) throw new Error('No booking reference provided');
console.log('🔍 Fetching Booking:', bookingRef);
const response = await apiClient.get(`/bookings/${bookingRef}`);
console.log('✅ Booking Response:', response);
// Handle wrapped response
return (response as any)?.data || response;
},
enabled: !!bookingRef,
retry: 1,
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: () => apiClient.get('/payments/methods'),
enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT',
});
const paymentMutation = useMutation({
mutationFn: async (paymentData: any) => {
const response = await apiClient.post('/payments/intent', paymentData);
return response;
},
onSuccess: async (data: any) => {
console.log('Payment intent created:', data);
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
paymentIntentId: data.id,
paymentMethod: selectedPaymentMethod,
});
refetch();
},
onError: (error: any) => {
console.error('Payment failed:', error);
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
},
});
const handlePayment = () => {
if (!selectedPaymentMethod) {
alert('Please select a payment method');
return;
}
paymentMutation.mutate({
bookingId: booking?.id,
amount: booking?.totalMinor || 0,
currency: booking?.currency || 'ETB',
paymentMethodId: selectedPaymentMethod,
});
};
const copyPNR = () => {
if (booking?.bookingRef) {
navigator.clipboard.writeText(booking.bookingRef);
setCopiedPNR(true);
setTimeout(() => setCopiedPNR(false), 2000);
}
};
const handleDownloadVoucher = async () => {
if (!booking || !booking.bookingRef) {
alert('Booking data not available. Please try again.');
return;
}
setIsGeneratingVoucher(true);
try {
console.log('📄 Generating voucher for booking:', booking);
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
await generateVoucherPDF(booking as any);
} catch (error) {
console.error('Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsGeneratingVoucher(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading Booking Details</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Fetching your booking information...</p>
</div>
</div>
);
}
if (error || !booking) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Booking Not Found</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{!bookingRef
? 'No booking reference provided in the URL.'
: `Unable to find booking with reference: ${bookingRef}`
}
</p>
<button onClick={() => refetch()} className="btn-secondary mb-2">Try Again</button>
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
</div>
</div>
);
}
const isPendingPayment = booking.status === 'PENDING_PAYMENT' || booking.status === 'DRAFT';
const isConfirmed = booking.status === 'TICKETED' || booking.status === 'CONFIRMED';
const isExpired = booking.status === 'EXPIRED';
const isCancelled = booking.status === 'CANCELLED';
console.log('📊 Booking Status:', booking.status);
console.log('📊 isPendingPayment:', isPendingPayment);
console.log('📊 isConfirmed:', isConfirmed);
console.log('📊 isExpired:', isExpired);
console.log('📊 isCancelled:', isCancelled);
const StatusBadge = () => {
const statusConfig = {
PENDING_PAYMENT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
DRAFT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
CONFIRMED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Confirmed' },
TICKETED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Ticketed' },
EXPIRED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Expired' },
CANCELLED: { color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', label: 'Cancelled' },
};
const config = statusConfig[booking.status as keyof typeof statusConfig] || statusConfig.DRAFT;
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold ${config.color}`}>
{isConfirmed && <CheckCircle2 className="w-4 h-4" />}
{config.label}
</span>
);
};
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Complete Payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Booking Reference: <span className="font-mono font-semibold">{booking.bookingRef}</span>
</p>
</div>
<StatusBadge />
</div>
{booking.createdAt && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-800 dark:text-amber-300">
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
</span>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Trip Summary</h2>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
{booking.passengers?.[0]?.seat?.seatClass && (
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{booking.passengers[0].seat.seatClass}
</span>
)}
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.origin?.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{booking.schedule?.origin?.city}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
</div>
{booking.schedule?.trainName && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{booking.schedule.trainName}
</span>
)}
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.destination?.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{booking.schedule?.destination?.city}
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-gray-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{booking.passengers?.length || 0} Passenger(s)
</span>
</div>
<div className="space-y-2">
{booking.passengers?.map((passenger: any, idx: number) => (
<div key={idx} className="flex items-center justify-between text-sm py-2 px-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<div className="text-gray-900 dark:text-white font-medium">{passenger.fullName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{passenger.category} Coach {passenger.seat?.coach}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-gray-900 dark:text-white">
Seat {passenger.seat?.number}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{passenger.seat?.seatClass}
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select Payment Method</h2>
{paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{paymentMethods.map((method: any) => (
<button
key={method.id}
onClick={() => setSelectedPaymentMethod(method.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
selectedPaymentMethod === method.id
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedPaymentMethod === method.id
? 'bg-primary/20 dark:bg-primary/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
{method.type === 'WALLET' ? (
<Wallet className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
) : (
<CreditCard className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
)}
</div>
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-white">{method.displayName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{method.currency}</div>
</div>
{selectedPaymentMethod === method.id && (
<Check className="w-5 h-5 text-primary" />
)}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No payment methods available
</div>
)}
</div>
<button
onClick={handlePayment}
disabled={!selectedPaymentMethod || paymentMutation.isPending}
className="w-full py-4 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-lg rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{paymentMutation.isPending ? 'Processing Payment...' : `Pay ${booking.displayCurrency} ${((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}`}
</button>
</div>
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 sticky top-6">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Order Summary</h2>
<div className="space-y-3 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''})</span>
<span className="font-semibold text-gray-900 dark:text-white">
{booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex justify-between">
<span className="text-lg font-bold text-gray-900 dark:text-white">Total</span>
<span className="text-2xl font-bold text-primary">
{booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
if (isConfirmed || isCancelled || isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 mb-6 text-center border border-gray-200 dark:border-gray-700">
<div className={`w-20 h-20 ${isConfirmed ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-gray-700'} rounded-full flex items-center justify-center mx-auto mb-4`}>
{isConfirmed ? (
<CheckCircle2 className="w-10 h-10 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-10 h-10 text-gray-500" />
)}
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{isConfirmed ? 'Booking Confirmed!' : isCancelled ? 'Booking Cancelled' : 'Booking Expired'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{isConfirmed ? 'Your tickets have been generated successfully' : isCancelled ? 'This booking has been cancelled' : 'This booking has expired'}
</p>
<div className="inline-flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-xl px-6 py-4">
<div className="text-left">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Booking Reference</div>
<div className="text-2xl font-mono font-bold text-primary">{booking.bookingRef}</div>
</div>
<button
onClick={copyPNR}
className="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center transition-all"
>
{copiedPNR ? <Check className="w-5 h-5 text-green-600" /> : <Copy className="w-5 h-5 text-gray-600 dark:text-gray-400" />}
</button>
</div>
<div className="flex flex-wrap gap-3 justify-center mt-6">
{isConfirmed && (
<>
<button
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary flex items-center gap-2"
>
{isGeneratingVoucher ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Generating...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Voucher
</>
)}
</button>
<button className="btn-secondary flex items-center gap-2">
<Download className="w-4 h-4" />
Download Tickets
</button>
<button className="btn-secondary flex items-center gap-2">
<Share2 className="w-4 h-4" />
Share
</button>
</>
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Journey Details</h2>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
{booking.passengers?.[0]?.seat?.seatClass && (
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{booking.passengers[0].seat.seatClass}
</span>
)}
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.origin?.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{booking.schedule?.origin?.city}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
</div>
{booking.schedule?.trainName && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{booking.schedule.trainName}
</span>
)}
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.destination?.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{booking.schedule?.destination?.city}
</div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
Passenger Details ({booking.passengers?.length || 0})
</h2>
<div className="space-y-4">
{booking.passengers?.map((passenger: any, idx: number) => (
<div key={idx} className="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
{idx + 1}
</span>
<h3 className="font-bold text-gray-900 dark:text-white">{passenger.fullName}</h3>
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
{passenger.category}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Coach:</span>
<div className="font-mono font-semibold text-gray-900 dark:text-white">
{passenger.seat?.coach || 'N/A'}
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Seat Number:</span>
<div className="font-semibold text-gray-900 dark:text-white">
{passenger.seat?.number || 'N/A'}
</div>
</div>
<div className="col-span-2">
<span className="text-gray-500 dark:text-gray-400">Class:</span>
<div className="font-medium text-gray-900 dark:text-white">
{passenger.seat?.seatClass || 'N/A'}
</div>
</div>
</div>
</div>
{isConfirmed && (
<div className="flex-shrink-0">
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
<QRCode
value={`TICKET:${booking.bookingRef}-${passenger.seat?.id || idx}`}
size={80}
level="M"
/>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="mt-6 text-center">
<button onClick={() => router.push('/booking/search')} className="btn-primary">
Book Another Trip
</button>
</div>
</div>
</div>
</div>
);
}
// Fallback for any other status
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-gray-500" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Unknown Booking Status</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Booking status: {booking.status}
</p>
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
</div>
</div>
);
}
export default function BookingDetailPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading...</h2>
</div>
</div>
}>
<BookingDetailContent />
</Suspense>
);
}

View File

@@ -22,7 +22,7 @@ export default function BookingLayout({
};
const currentStep = stepMap[pathname] || 'search';
const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation';
const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation' && pathname !== '/booking/detail' && pathname !== '/booking/lookup';
return (
<div>

View File

@@ -0,0 +1,70 @@
"use client";
import { Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function BookingLookupPage() {
const router = useRouter();
const [bookingRef, setBookingRef] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = bookingRef.trim().toUpperCase();
if (!trimmed) {
setError("Please enter a booking reference");
return;
}
router.push(`/booking/detail?ref=${trimmed}`);
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-900">
<div className="w-full max-w-md">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-[rgb(20,113,76)] bg-opacity-10 rounded-full mb-4">
<Search className="w-8 h-8 text-[rgb(20,113,76)]" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Find Your Booking
</h1>
<p className="text-gray-600 dark:text-gray-400">
Enter your booking reference (PNR) to view details
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Booking Reference (PNR)
</label>
<input
type="text"
value={bookingRef}
onChange={(e) => {
setBookingRef(e.target.value.toUpperCase());
setError("");
}}
placeholder="Enter your PNR"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono"
/>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
<button
type="submit"
className="w-full bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Search className="w-5 h-5" />
Search Booking
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -557,11 +557,14 @@ export default function PassengersPage() {
nationalId: p.nationalId,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
phone: p.phone,
email: p.email,
passportIssueDate: p.passportIssueDate,
passportExpiryDate: p.passportExpiryDate,
passportIssuingAuthority: p.passportIssuingAuthority,
phone: p.phone || '',
email: p.email || '',
isPrimaryPassenger: i === 0,
passengerId: i === 0 && passengerId ? passengerId : undefined,
}));
}))
const deviceId = typeof window !== 'undefined'
? (localStorage.getItem('deviceId') || crypto.randomUUID())

View File

@@ -4,11 +4,9 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { LanguageSwitcher } from "./LanguageSwitcher";
export default function AppHeader() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
@@ -31,13 +29,7 @@ export default function AppHeader() {
}
};
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">
@@ -59,35 +51,15 @@ export default function AppHeader() {
/>
</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>
)}
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8">
<Link
href="/booking/lookup"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
My Booking
</Link>
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
@@ -133,39 +105,13 @@ export default function AppHeader() {
{/* 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="/booking/lookup"
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)}
>
My Booking
</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"

View File

@@ -0,0 +1,392 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
interface VoucherData {
bookingRef: string;
status: string;
passengers: Array<{
fullName: string;
category: string;
seat?: {
number: string;
coach: string;
seatClass: string;
};
}>;
schedule: {
trainNumber: string;
trainName?: string;
origin: {
name: string;
code: string;
city: string;
};
destination: {
name: string;
code: string;
city: string;
};
departureAt: string;
arrivalAt: string;
};
totalMinor: number;
currency: string;
bookingType: string;
createdAt: string;
}
export const generateVoucherPDF = async (booking: VoucherData) => {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 15;
let yPos = margin;
// Colors
const primaryColor = [20, 113, 76]; // EDR Green
const darkGray = [51, 51, 51];
const mediumGray = [102, 102, 102];
const lightGray = [200, 200, 200];
// ============ HEADER ============
// Company branding strip
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.rect(0, 0, pageWidth, 30, 'F');
// Load and add logo
try {
const logoImg = await fetch('/edr-logo.png');
const logoBlob = await logoImg.blob();
const logoDataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(logoBlob);
});
// Create image to get dimensions
const img = new Image();
await new Promise((resolve) => {
img.onload = resolve;
img.src = logoDataUrl;
});
// Calculate aspect ratio and dimensions
const logoHeight = 18;
const logoWidth = (img.width / img.height) * logoHeight;
// Add logo on left side with proper aspect ratio
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight);
// Company name next to logo
doc.setTextColor(255, 255, 255);
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', margin + logoWidth + 5, 20);
} catch (error) {
console.error('Failed to load logo:', error);
// Fallback: just show text centered
doc.setTextColor(255, 255, 255);
doc.setFontSize(24);
doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' });
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
}
yPos = 40;
// ============ TITLE & STATUS ============
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' });
yPos += 10;
// Status badge (simplified)
const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status;
const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8];
doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]);
doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' });
yPos += 12;
// ============ QR CODE ============
// Generate QR code data URL
const canvas = document.createElement('canvas');
const QRCode = (await import('qrcode')).default;
const qrSize = 35; // 35mm = 3.5cm
await QRCode.toCanvas(canvas, booking.bookingRef, {
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
const qrDataUrl = canvas.toDataURL('image/png');
// Place QR code at top-right
const qrX = pageWidth - margin - qrSize;
const qrY = yPos;
doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' });
// ============ BOOKING REFERENCE ============
doc.setFillColor(245, 245, 245);
doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F');
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('BOOKING REFERENCE', margin + 5, yPos + 6);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(booking.bookingRef, margin + 5, yPos + 14);
yPos += 25;
// ============ JOURNEY DETAILS ============
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('JOURNEY DETAILS', margin, yPos);
yPos += 8;
// Route box
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
doc.setLineWidth(0.5);
doc.rect(margin, yPos, pageWidth - margin * 2, 40);
// Origin
doc.setFontSize(9);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('FROM', margin + 5, yPos + 6);
doc.setFontSize(16);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFont('helvetica', 'bold');
doc.text(booking.schedule.origin.code, margin + 5, yPos + 14);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(booking.schedule.origin.name, margin + 5, yPos + 20);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.text(booking.schedule.origin.city, margin + 5, yPos + 25);
// Departure time
const departureDate = new Date(booking.schedule.departureAt);
doc.setFontSize(14);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.setFont('helvetica', 'bold');
doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38);
// Arrow
doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.setLineWidth(1);
const arrowStartX = pageWidth / 2 - 10;
const arrowEndX = pageWidth / 2 + 10;
const arrowY = yPos + 20;
// Draw arrow line
doc.line(arrowStartX, arrowY, arrowEndX, arrowY);
// Draw arrow head manually with lines
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2);
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2);
// Destination
const destX = pageWidth - margin - 50;
doc.setFontSize(9);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('TO', destX, yPos + 6);
doc.setFontSize(16);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFont('helvetica', 'bold');
doc.text(booking.schedule.destination.code, destX, yPos + 14);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(booking.schedule.destination.name, destX, yPos + 20);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.text(booking.schedule.destination.city, destX, yPos + 25);
// Arrival time
const arrivalDate = new Date(booking.schedule.arrivalAt);
doc.setFontSize(14);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.setFont('helvetica', 'bold');
doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38);
yPos += 48;
// Train info
doc.setFillColor(250, 250, 250);
doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F');
doc.setFontSize(9);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('TRAIN', margin + 5, yPos + 5);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFont('helvetica', 'bold');
doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9);
if (booking.schedule.trainName) {
doc.setFont('helvetica', 'normal');
doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9);
}
yPos += 18;
// ============ PASSENGERS ============
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('PASSENGERS', margin, yPos);
yPos += 8;
// Passenger table
const passengerData = booking.passengers.map((p, idx) => [
(idx + 1).toString(),
p.fullName,
p.category,
p.seat?.number || '-',
p.seat?.coach || '-',
p.seat?.seatClass || '-',
]);
autoTable(doc, {
startY: yPos,
head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']],
body: passengerData,
theme: 'striped',
headStyles: {
fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]],
textColor: [255, 255, 255],
fontSize: 9,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 9,
textColor: [darkGray[0], darkGray[1], darkGray[2]],
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: margin, right: margin },
});
yPos = (doc as any).lastAutoTable.finalY + 10;
// ============ PAYMENT SUMMARY ============
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('PAYMENT SUMMARY', margin, yPos);
yPos += 8;
doc.setFillColor(250, 250, 250);
doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F');
doc.setFontSize(10);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('Total Amount', margin + 5, yPos + 7);
doc.setFontSize(16);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.setFont('helvetica', 'bold');
doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' });
doc.setFontSize(9);
doc.setTextColor(34, 197, 94);
doc.setFont('helvetica', 'bold');
doc.text('✓ PAID', margin + 5, yPos + 15);
yPos += 28;
// ============ INSTRUCTIONS ============
doc.setFillColor(252, 211, 77);
doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F');
doc.setFontSize(9);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
doc.setFont('helvetica', 'bold');
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11);
doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15);
// ============ FOOTER ============
const footerY = pageHeight - 25;
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
doc.line(margin, footerY, pageWidth - margin, footerY);
doc.setFontSize(8);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
doc.setFont('helvetica', 'normal');
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
doc.setFontSize(7);
doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
// Watermark (removed rotation as it may cause issues)
doc.setTextColor(240, 240, 240);
doc.setFontSize(50);
doc.setFont('helvetica', 'bold');
doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
// Save PDF
doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`);
};

View File

@@ -1,6 +1,3 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
/** @type {import('tailwindcss').Config} */
export default {
@@ -90,3 +87,4 @@ export default {
},
plugins: [],
};

50
pnpm-lock.yaml generated
View File

@@ -647,6 +647,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.59.0
version: 5.101.0(react@18.3.1)
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
axios:
specifier: ^1.7.7
version: 1.17.0
@@ -656,12 +659,21 @@ importers:
date-fns:
specifier: ^3.0.0
version: 3.6.0
jspdf:
specifier: ^4.2.1
version: 4.2.1
jspdf-autotable:
specifier: ^5.0.8
version: 5.0.8(jspdf@4.2.1)
lucide-react:
specifier: ^0.446.0
version: 0.446.0(react@18.3.1)
next:
specifier: ^14.2.0
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
qrcode:
specifier: ^1.5.4
version: 1.5.4
qrcode.react:
specifier: ^3.1.0
version: 3.2.0(react@18.3.1)
@@ -7769,9 +7781,21 @@ packages:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jspdf-autotable@5.0.8:
resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==}
peerDependencies:
jspdf: ^2 || ^3 || ^4
jspdf@3.0.4:
resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==}
jspdf@4.2.1:
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
jsprim@1.4.2:
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
engines: {node: '>=0.6.0'}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -19744,6 +19768,10 @@ snapshots:
ms: 2.1.3
semver: 7.8.2
jspdf-autotable@5.0.8(jspdf@4.2.1):
dependencies:
jspdf: 4.2.1
jspdf@3.0.4:
dependencies:
'@babel/runtime': 7.29.7
@@ -19755,6 +19783,28 @@ snapshots:
dompurify: 3.4.8
html2canvas: 1.4.1
jspdf@4.2.1:
dependencies:
'@babel/runtime': 7.29.7
fast-png: 6.4.0
fflate: 0.8.3
optionalDependencies:
canvg: 3.0.11
core-js: 3.49.0
dompurify: 3.4.8
html2canvas: 1.4.1
jsprim@1.4.2:
dependencies:
'@babel/runtime': 7.29.7
fast-png: 6.4.0
fflate: 0.8.3
optionalDependencies:
canvg: 3.0.11
core-js: 3.49.0
dompurify: 3.4.8
html2canvas: 1.4.1
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9