diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8413bf71..7de5c5239 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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: diff --git a/.gitignore b/.gitignore index 0e3f0986f..ffdc4b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ .idea/ .vscode/ .npmrc +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 9ebd1cc01..c9f2d6282 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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 }), ), }, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 54dcea344..285e38bf0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 44a31c151..22c11b8f1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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, }; } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 48b84c4b6..922d7409b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 4cf4ce9d3..63b675157 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -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({ diff --git a/apps/edr-passenger-web/portal/package.json b/apps/edr-passenger-web/portal/package.json index 8e063d85a..8323c3bb0 100644 --- a/apps/edr-passenger-web/portal/package.json +++ b/apps/edr-passenger-web/portal/package.json @@ -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", diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 1a36ac04b..f7cabc212 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -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() { - {/* Trip Summary */} + {/* Trip Summary with QR Code */}
-
-
- +
+ {/* QR Code Section */} +
+ +

Scan at gate

-

Trip details

-
-
-
-
-

Train number

-

{selectedSchedule?.trainNumber}

-
-
-

Route

-

{selectedSchedule?.origin} → {selectedSchedule?.destination}

-
- {selectedSchedule?.selectedSeatClassName && ( -
-

Class

-

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+ + {/* Trip Details */} +
+
+
+
- )} -
-
-
-

Departure

-

- {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} -

+

Trip details

-
-

Arrival

-

- {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} -

-
-
-

Duration

-

{selectedSchedule?.duration}

+
+
+
+

Train number

+

{selectedSchedule?.trainNumber}

+
+
+

Route

+

{selectedSchedule?.origin} → {selectedSchedule?.destination}

+
+ {selectedSchedule?.selectedSeatClassName && ( +
+

Class

+

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+
+ )} +
+
+
+

Departure

+

+ {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} +

+
+
+

Arrival

+

+ {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} +

+
+
+

Duration

+

{selectedSchedule?.duration}

+
+
@@ -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 (
-
- {/* Ticket Info */} -
-
-
-

{passenger.name}

-

Passenger {index + 1}

-
- CONFIRMED -
- -
-
-

Ticket Number

-

{ticketNumber}

-
-
-

Date of Birth

-

{format(new Date(passenger.dateOfBirth), 'PP')}

-
-
-

Nationality

-

{passenger.nationality}

-
-
-

Seat

-

{passenger.seatNumber || 'Will be assigned'}

-
-
- -
-

- 📱 Show this QR code at the gate for boarding -

+ {/* Ticket Info */} +
+
+
+

{passenger.name}

+

Passenger {index + 1}

+ CONFIRMED
- - {/* QR Code */} -
- -

Scan at gate

+ +
+
+

Ticket Number

+

{ticketNumber}

+
+
+

Date of Birth

+

{format(new Date(passenger.dateOfBirth), 'PP')}

+
+
+

Nationality

+

{passenger.nationality}

+
+
+

Seat

+

{passenger.seatNumber || 'Will be assigned'}

+
@@ -249,13 +293,24 @@ export default function ConfirmationPage() {
{/* Action Buttons */} -
+
+ + +
+
+ ); + } + + 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 ( + + {isConfirmed && } + {config.label} + + ); + }; + + if (isPendingPayment && !isExpired) { + return ( +
+
+
+ +
+
+
+

Complete Payment

+

+ Booking Reference: {booking.bookingRef} +

+
+ +
+ + {booking.createdAt && ( +
+ + + Booking created on {format(new Date(booking.createdAt), 'PPpp')} + +
+ )} +
+ +
+ +
+ +
+

Trip Summary

+ +
+
+ Your Journey + {booking.passengers?.[0]?.seat?.seatClass && ( + + {booking.passengers[0].seat.seatClass} + + )} +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.origin?.name} +
+
+ {booking.schedule?.origin?.city} +
+
+ + {/* Journey Info */} +
+
+
+ + + + Train {booking.schedule?.trainNumber} +
+ {booking.schedule?.trainName && ( + + {booking.schedule.trainName} + + )} +
+
+ + {/* Destination */} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.destination?.name} +
+
+ {booking.schedule?.destination?.city} +
+
+
+
+ +
+
+ + + {booking.passengers?.length || 0} Passenger(s) + +
+
+ {booking.passengers?.map((passenger: any, idx: number) => ( +
+
+
{passenger.fullName}
+
+ {passenger.category} • Coach {passenger.seat?.coach} +
+
+
+
+ Seat {passenger.seat?.number} +
+
+ {passenger.seat?.seatClass} +
+
+
+ ))} +
+
+
+ +
+

Select Payment Method

+ + {paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? ( +
+ {paymentMethods.map((method: any) => ( + + ))} +
+ ) : ( +
+ No payment methods available +
+ )} +
+ + +
+ +
+
+

Order Summary

+ +
+
+ Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''}) + + {booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)} + +
+
+ +
+
+ Total + + {booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)} + +
+
+
+
+
+
+
+
+ ); + } + + if (isConfirmed || isCancelled || isExpired) { + return ( +
+
+
+ +
+
+ {isConfirmed ? ( + + ) : ( + + )} +
+

+ {isConfirmed ? 'Booking Confirmed!' : isCancelled ? 'Booking Cancelled' : 'Booking Expired'} +

+

+ {isConfirmed ? 'Your tickets have been generated successfully' : isCancelled ? 'This booking has been cancelled' : 'This booking has expired'} +

+ +
+
+
Booking Reference
+
{booking.bookingRef}
+
+ +
+ +
+ {isConfirmed && ( + <> + + + + + )} +
+
+ +
+

Journey Details

+ +
+
+ Your Journey + {booking.passengers?.[0]?.seat?.seatClass && ( + + {booking.passengers[0].seat.seatClass} + + )} +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.origin?.name} +
+
+ {booking.schedule?.origin?.city} +
+
+ + {/* Journey Info */} +
+
+
+ + + + Train {booking.schedule?.trainNumber} +
+ {booking.schedule?.trainName && ( + + {booking.schedule.trainName} + + )} +
+
+ + {/* Destination */} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.destination?.name} +
+
+ {booking.schedule?.destination?.city} +
+
+
+
+
+ +
+

+ Passenger Details ({booking.passengers?.length || 0}) +

+ +
+ {booking.passengers?.map((passenger: any, idx: number) => ( +
+
+
+
+ + {idx + 1} + +

{passenger.fullName}

+ + {passenger.category} + +
+ +
+
+ Coach: +
+ {passenger.seat?.coach || 'N/A'} +
+
+
+ Seat Number: +
+ {passenger.seat?.number || 'N/A'} +
+
+
+ Class: +
+ {passenger.seat?.seatClass || 'N/A'} +
+
+
+
+ + {isConfirmed && ( +
+
+ +
+
+ )} +
+
+ ))} +
+
+ +
+ +
+
+
+
+ ); + } + + // Fallback for any other status + return ( +
+
+
+ +
+

Unknown Booking Status

+

+ Booking status: {booking.status} +

+ +
+
+ ); +} + +export default function BookingDetailPage() { + return ( + +
+
+

Loading...

+
+
+ }> + +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx index e326bd376..dd01d4d49 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -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 (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx new file mode 100644 index 000000000..e65882fd2 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -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 ( +
+
+
+
+
+ +
+

+ Find Your Booking +

+

+ Enter your booking reference (PNR) to view details +

+
+ +
+
+ + { + 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 && ( +

{error}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index a9fdf5b35..d2ed43878 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -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()) diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index b73bac953..3b6c19336 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -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 (
@@ -59,35 +51,15 @@ export default function AppHeader() { /> - {/* Desktop Menu - only show for landing pages */} - {isLandingPage && ( -
- - Home - - - Services - - - About - - - Contact - -
- )} + {/* Desktop Menu */} +
+ + My Booking + +
{/* Right Actions */}
@@ -133,39 +105,13 @@ export default function AppHeader() { {/* Mobile Menu */} {isOpen && (
- {isLandingPage && ( - <> - setIsOpen(false)} - > - Home - - setIsOpen(false)} - > - Services - - setIsOpen(false)} - > - About - - setIsOpen(false)} - > - Contact - - - )} - + setIsOpen(false)} + > + My Booking + ; + 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((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`); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 948616110..d0cb89d19 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -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: [], }; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80b94aa54..150be6988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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