From bf3fa746ebcd2eda8d22a58b6043c4a4640bd4f6 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 7 Jul 2026 21:19:04 +0300 Subject: [PATCH] Update manage booking --- .../notifications/notifications.service.ts | 6 +- .../portal/src/app/booking/detail/page.tsx | 73 +++- .../portal/src/app/booking/seats/page.tsx | 79 +++- .../portal/src/lib/generate-voucher.ts | 386 ++++++++++-------- 4 files changed, 349 insertions(+), 195 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 9a7b4311c..43d1e4d46 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -285,7 +285,11 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const amount = this.formatAmount(booking ?? payload.booking); const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB'; - const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`; + // /booking/confirmation only reads from the in-session booking store, so it's a dead + // link once opened outside that session (a different device, or later on the same + // one) — exactly the case an SMS/email link is for. /booking/detail fetches the + // booking fresh from the API by ref, so it works standalone. + const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; // IN_APP — always created. await this.createInAppNotification( diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index a52a10612..a3e7f06fc 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { format } from 'date-fns'; import { formatTime, getTimePeriod } from '@/utils/format'; +import { formatFare } from '@/utils/fare-utils'; import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; import QRCode from 'qrcode.react'; @@ -225,6 +226,34 @@ function BookingDetailContent() { ); }; + // /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 = + // outbound, leg 2 = return) — /booking/payment's fare breakdown, by contrast, shows one + // combined row per passenger with an Outbound/Return sub-split. Group leg rows back + // together here so both pages present the same per-passenger total, not a doubled list + // of half-fare rows. + const isRoundTripBooking = booking.bookingType === 'ROUND_TRIP'; + const farePassengers = (() => { + const rows: any[] = booking.passengers || []; + if (!isRoundTripBooking) { + return rows.map((p) => ({ fullName: p.fullName, category: p.category, fareMinor: p.fareMinor ?? 0 })); + } + const grouped = new Map(); + rows.forEach((p) => { + const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`; + const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundFareMinor: 0, returnFareMinor: 0 }; + if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0; + else entry.outboundFareMinor = p.fareMinor ?? 0; + grouped.set(key, entry); + }); + return Array.from(grouped.values()).map((p) => ({ + fullName: p.fullName, + category: p.category, + fareMinor: p.outboundFareMinor + p.returnFareMinor, + outboundFareMinor: p.outboundFareMinor, + returnFareMinor: p.returnFareMinor, + })); + })(); + // Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per // passenger, Total with a loading spinner while a currency conversion is in flight, and // a note confirming what will actually be charged once a payment method is selected. @@ -239,19 +268,39 @@ function BookingDetailContent() {

Fare breakdown

- {(booking.passengers || []).map((passenger: any, idx: number) => ( -
- - {passenger.fullName || `Passenger ${idx + 1}`} - {passenger.category === 'CHILD' && ( - (CHILD) + {farePassengers.map((passenger: any, idx: number) => { + const isChildPassenger = passenger.category === 'CHILD'; + const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0; + return ( +
+
+ + {passenger.fullName || `Passenger ${idx + 1}`} + {isChildPassenger && ( + + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) + + )} + + + {formatFare(passenger.fareMinor ?? 0, displayCurrency)} + +
+ {isRoundTripBooking && !isFreeChild && ( +
+
+ Outbound + {formatFare(passenger.outboundFareMinor ?? 0, displayCurrency)} +
+
+ Return + {formatFare(passenger.returnFareMinor ?? 0, displayCurrency)} +
+
)} - - - {displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)} - -
- ))} +
+ ); + })}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index d8ae5c0e5..ea5fa4de1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -308,6 +308,11 @@ export default function SeatsPage() { // hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a // deliberate deselect/re-pick afterwards. const restoredLegRef = useRef(null); + // Indices whose current passengerSeatMap entry came from the restoration above, not a + // deliberate click this visit. A passenger's next click should be treated as their + // first real pick (no fare-change modal) even though the map already has an entry for + // them — only a click AFTER that (replacing their own real pick) is an actual change. + const restoredIndicesRef = useRef>(new Set()); useEffect(() => { const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`; if (restoredLegRef.current === legKey) return; @@ -322,6 +327,7 @@ export default function SeatsPage() { }); if (Object.keys(restored).length > 0) { setPassengerSeatMap(restored); + restoredIndicesRef.current = new Set(Object.keys(restored).map(Number)); } }, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]); @@ -471,6 +477,21 @@ export default function SeatsPage() { setPendingCoachLabel(coach.label); setShowCoachPreview(false); + // The user already confirmed this fare change in the coach-switch modal above — reset + // the per-leg baseline to the NEW coach type's fare so the very next seat pick is + // compared against it, not the stale pre-switch fare. Without this, picking any seat + // right after switching would immediately re-trigger the fare-change modal in + // handleSeatClick for a change the user already agreed to. + if (newFare != null) { + if (isRoundTrip && currentJourneyType === "inbound") { + originalFaresRef.current.inbound = newFare; + } else if (isRoundTrip) { + originalFaresRef.current.outbound = newFare; + } else { + originalFaresRef.current.oneWay = newFare; + } + } + // For package bookings, sync the stored tier price with the new coach type's fare // so the review page totals reflect the switched coach type. if (isPackageBooking && packageId && newFare != null) { @@ -786,6 +807,8 @@ export default function SeatsPage() { next[activePassengerIndex] = seatId; } setPassengerSeatMap(next); + // Whatever happens now is a deliberate pick — no longer just a restored hold. + restoredIndicesRef.current.delete(activePassengerIndex); if (!isDeselecting) { // Move on to the next passenger who still needs a seat — one passenger at a time @@ -806,22 +829,30 @@ export default function SeatsPage() { ); if (takenByOther) return; - const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; + const currentSeatId = passengerSeatMap[activePassengerIndex]; + // A seat restored from a still-valid hold (see the restore effect above) isn't a + // choice this passenger has made THIS visit — their next click is their first real + // pick, not a "change", even though the map already has an entry for them. + const isRestoredNotYetChosen = restoredIndicesRef.current.has(activePassengerIndex); + const isDeselecting = currentSeatId === seatId; if (isDeselecting) { + restoredIndicesRef.current.delete(activePassengerIndex); commitSeatAssignment(seatId); return; } - // Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare - // differs from what the user originally selected (e.g. after switching coach type - // via the preview, or just picking a pricier berth) — or from another passenger's - // already-selected seat — needs a heads-up before it's applied. + // A fare-change warning only makes sense when there's an actual prior selection to + // change FROM — either this same passenger swapping their own pick for a + // differently-priced one, or picking a seat priced differently from another + // passenger's already-selected seat this session. A passenger's very first pick has + // neither, so it must never trigger this modal, regardless of the coach type's + // "starting from" fare. const newSeat = validSeats?.find((s: any) => s.id === seatId); const newFare = newSeat ? getSeatFare(newSeat) : null; if (newFare != null) { let referenceFare: number | null = null; - let referenceLabel = "the fare you originally selected"; + let referenceLabel = "your previously selected seat"; if (isPackageBooking) { // For package bookings, compare against the stored tier price (per leg). @@ -829,22 +860,28 @@ export default function SeatsPage() { if (pkgLegFare != null && newFare !== pkgLegFare) { referenceFare = pkgLegFare; } + } else if (currentSeatId && !isRestoredNotYetChosen) { + // This passenger already has a different seat picked — compare against that + // actual, concrete selection. + const currentSeat = validSeats?.find((s: any) => s.id === currentSeatId); + const currentFare = currentSeat ? getSeatFare(currentSeat) : null; + if (currentFare != null && currentFare !== newFare) { + referenceFare = currentFare; + } } else { - if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) { - referenceFare = originalFareForCurrentLeg; - } else { - const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { - if (Number(idx) === activePassengerIndex) return false; - const otherSeat = validSeats?.find((s: any) => s.id === sid); - const otherFare = otherSeat ? getSeatFare(otherSeat) : null; - return otherFare != null && otherFare !== newFare; - }); + // First pick for this passenger — only compare against another passenger's + // already-selected seat in this session. + const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { + if (Number(idx) === activePassengerIndex) return false; + const otherSeat = validSeats?.find((s: any) => s.id === sid); + const otherFare = otherSeat ? getSeatFare(otherSeat) : null; + return otherFare != null && otherFare !== newFare; + }); - if (differingEntry) { - const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); - referenceFare = otherSeat ? getSeatFare(otherSeat) : null; - referenceLabel = "another already-selected seat"; - } + if (differingEntry) { + const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); + referenceFare = otherSeat ? getSeatFare(otherSeat) : null; + referenceLabel = "another already-selected seat"; } } @@ -898,7 +935,7 @@ export default function SeatsPage() { commitSeatAssignment(seatId); }, - [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip], + [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip], ); const allSeatsAssigned = diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 37f421d71..9d15af7d8 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,5 +1,4 @@ import jsPDF from 'jspdf'; -import autoTable from 'jspdf-autotable'; import QRCode from 'qrcode'; interface ScheduleInfo { @@ -30,12 +29,24 @@ interface PassengerVoucherData { createdAt: string; } -// ─── shared drawing helpers ─────────────────────────────────────────────────── +// ─── palette ─────────────────────────────────────────────────────────────── +// A restrained, mostly-neutral palette (ink / slate / hairline / surface) with the +// brand green reserved for the few elements that should draw the eye — the PNR, +// times, and the fare — rather than tinting large areas of the page. -const PRIMARY = [20, 113, 76] as const; -const DARK = [51, 51, 51] as const; -const MED = [102, 102, 102] as const; -const LIGHT = [200, 200, 200] as const; +const BRAND = [20, 113, 76] as const; // brand green — accents only +const BRAND_SOFT = [235, 245, 240] as const; // pale green tint for subtle fills +const INK = [24, 28, 33] as const; // headings, high-emphasis text +const BODY = [71, 85, 105] as const; // slate-600 — body text +const MUTED = [148, 163, 184] as const; // slate-400 — labels/captions +const HAIRLINE = [226, 232, 240] as const; // slate-200 — borders/dividers +const SURFACE = [250, 250, 251] as const; // near-white card fill +const SUCCESS = [21, 128, 61] as const; // green-700 +const AMBER_TEXT = [146, 64, 14] as const; // amber-800 +const AMBER_FILL = [255, 251, 235] as const; // amber-50 +const AMBER_BORDER = [251, 191, 36] as const; // amber-400 + +const PAGE_MARGIN = 18; // ─── QR code ─────────────────────────────────────────────────────────────── // Encodes everything a gate scanner needs to verify this specific ticket without @@ -63,7 +74,7 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise { const pageWidth = doc.internal.pageSize.getWidth(); + const bandHeight = 24; - doc.setFillColor(...PRIMARY); - doc.rect(0, 0, pageWidth, 30, 'F'); + doc.setFillColor(...BRAND); + doc.rect(0, 0, pageWidth, bandHeight, 'F'); try { const logoImg = await fetch('/edr-logo.png'); @@ -87,206 +117,243 @@ async function drawHeader(doc: jsPDF, margin: number): Promise { }); const img = new Image(); await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); - const logoH = 18; + const logoH = 13; const logoW = (img.width / img.height) * logoH; - doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH); + const textX = margin + logoW + 5; + doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH); doc.setTextColor(255, 255, 255); - doc.setFontSize(18); doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14); - doc.setFontSize(9); doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', margin + logoW + 5, 20); + doc.setFontSize(13); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', textX, bandHeight / 2 - 1); + doc.setFontSize(7.5); doc.setFont('helvetica', 'normal'); + doc.setTextColor(230, 240, 236); + doc.text('E-TICKET · BOARDING VOUCHER', textX, bandHeight / 2 + 5, { charSpace: 0.4 }); } catch { doc.setTextColor(255, 255, 255); - doc.setFontSize(22); doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' }); - doc.setFontSize(9); doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' }); + doc.setFontSize(15); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, bandHeight / 2 - 1, { align: 'center' }); + doc.setFontSize(7.5); doc.setFont('helvetica', 'normal'); + doc.setTextColor(230, 240, 236); + doc.text('E-TICKET · BOARDING VOUCHER', pageWidth / 2, bandHeight / 2 + 5, { align: 'center', charSpace: 0.4 }); } - return 40; + return bandHeight + 16; } -function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number { - const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status; - const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8]; +// ─── status pill ─────────────────────────────────────────────────────────── + +function drawStatusPill(doc: jsPDF, status: string, x: number, y: number, align: 'left' | 'right' = 'right'): void { + const isConfirmed = status === 'TICKETED' || status === 'CONFIRMED'; + const text = isConfirmed ? 'CONFIRMED' : status; + const color = isConfirmed ? SUCCESS : [180, 83, 9] as const; + + doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); + const textWidth = doc.getTextWidth(text.toUpperCase()); + const padX = 3.5; + const pillH = 5.5; + const pillW = textWidth + padX * 2; + const pillX = align === 'right' ? x - pillW : x; + doc.setFillColor(color[0], color[1], color[2]); - doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F'); + doc.roundedRect(pillX, y, pillW, pillH, pillH / 2, pillH / 2, 'F'); doc.setTextColor(255, 255, 255); - doc.setFontSize(9); doc.setFont('helvetica', 'bold'); - doc.text(label, pageWidth / 2, y + 1, { align: 'center' }); - return y + 12; + doc.text(text.toUpperCase(), pillX + pillW / 2, y + pillH / 2 + 1.4, { align: 'center', charSpace: 0.3 }); } -function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number { - const boxHeight = 32; - const qrSize = 24; - const qrPad = 3; - const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0; +// ─── hero card: PNR + ticket number + QR ────────────────────────────────── - doc.setFillColor(245, 245, 245); - doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F'); +function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, status: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number { + const cardH = 32; + const qrSize = 22; + const qrPad = 2.5; + const cardSize = qrSize + qrPad * 2; - // Booking reference (top-left) - doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); - doc.text('BOOKING REFERENCE', margin + 5, y + 8); - doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); - doc.text(bookingRef, margin + 5, y + 18); + doc.setFillColor(...SURFACE); + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.3); + doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD'); - // Ticket number, stacked below — leaves room on the right for the QR block - const textRightBound = pageWidth - margin - qrBlockWidth - 5; - doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); - doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' }); - doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); - doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' }); + const padX = 7; + drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right'); + + label(doc, 'Booking reference', margin + padX, y + 12); + doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold'); + doc.text(bookingRef, margin + padX, y + 23, { charSpace: 0.6 }); + + label(doc, 'Ticket no.', margin + padX, y + 28.5); + doc.setTextColor(...BODY); doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(ticketNumber, margin + padX + 22, y + 28.7); - // QR code — clean white card with a thin border, right-aligned in the box if (qrDataUrl) { - const cardSize = qrSize + qrPad * 2; const cardX = pageWidth - margin - cardSize - 3; - const cardY = y + (boxHeight - cardSize) / 2; + const cardY = y + (cardH - cardSize) / 2; doc.setFillColor(255, 255, 255); - doc.setDrawColor(...LIGHT); - doc.setLineWidth(0.4); + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.3); doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD'); doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize); } - return y + boxHeight + 6; + return y + cardH + 10; } -function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number { - doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); - doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y); - y += 7; +// ─── journey card ────────────────────────────────────────────────────────── - doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5); - doc.rect(margin, y, pageWidth - margin * 2, 40); +function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number { + const cardW = pageWidth - margin * 2; + const routeH = 30; + const trainRowH = 9; + const cardH = routeH + trainRowH; - // Origin - doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text('FROM', margin + 5, y + 6); - doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); - doc.text(schedule.origin.code, margin + 5, y + 14); - doc.setFontSize(9); doc.setFont('helvetica', 'normal'); - doc.text(schedule.origin.name, margin + 5, y + 20); - doc.setFontSize(8); doc.setTextColor(...MED); - doc.text(schedule.origin.city, margin + 5, y + 25); + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.3); + doc.roundedRect(margin, y, cardW, cardH, 3, 3, 'D'); - const dep = new Date(schedule.departureAt); - doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); - doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33); - doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38); - - // Arrow - doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8); - const ax = pageWidth / 2, ay = y + 20; - doc.line(ax - 10, ay, ax + 10, ay); - doc.line(ax + 10, ay, ax + 7, ay - 2); - doc.line(ax + 10, ay, ax + 7, ay + 2); - - // Destination - const dx = pageWidth - margin - 50; - doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text('TO', dx, y + 6); - doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); - doc.text(schedule.destination.code, dx, y + 14); - doc.setFontSize(9); doc.setFont('helvetica', 'normal'); - doc.text(schedule.destination.name, dx, y + 20); - doc.setFontSize(8); doc.setTextColor(...MED); - doc.text(schedule.destination.city, dx, y + 25); - - const arr = new Date(schedule.arrivalAt); - doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); - doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, y + 33); - doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38); - - y += 47; - - // Train info bar - doc.setFillColor(248, 248, 248); - doc.rect(margin, y, pageWidth - margin * 2, 12, 'F'); - doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text('TRAIN', margin + 5, y + 5); - doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); - doc.text(schedule.trainNumber + (schedule.trainName ? ` — ${schedule.trainName}` : ''), margin + 20, y + 9); - if (schedule.seatClass) { - doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED); - doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' }); + if (legLabel) { + doc.setFillColor(...BRAND); + doc.roundedRect(margin + 6, y - 3, doc.getTextWidth(legLabel.toUpperCase()) + 7, 6, 3, 3, 'F'); + doc.setTextColor(255, 255, 255); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); + doc.text(legLabel.toUpperCase(), margin + 6 + (doc.getTextWidth(legLabel.toUpperCase()) + 7) / 2, y, { align: 'center', charSpace: 0.3 }); } - return y + 18; + const padX = 8; + const topY = y + (legLabel ? 12 : 8); + + // Origin block + label(doc, 'From', margin + padX, topY); + doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(schedule.origin.code, margin + padX, topY + 8); + doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); + doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 13); + + const dep = new Date(schedule.departureAt); + doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); + doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 20.5); + doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 25); + + // Destination block (right-aligned) + const dx = pageWidth - margin - padX; + label(doc, 'To', dx, topY, { align: 'right' }); + doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(schedule.destination.code, dx, topY + 8, { align: 'right' }); + doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); + doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 13, { align: 'right' }); + + const arr = new Date(schedule.arrivalAt); + doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); + doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 20.5, { align: 'right' }); + doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 25, { align: 'right' }); + + // Dashed route line with endpoint markers, connecting the two blocks + const lineY = topY + 8.5; + const lineX1 = margin + padX + 24; + const lineX2 = dx - 24; + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.5); + doc.setLineDashPattern([1, 1.2], 0); + doc.line(lineX1, lineY, lineX2, lineY); + doc.setLineDashPattern([], 0); + doc.setFillColor(...BRAND); + doc.circle(lineX1, lineY, 0.9, 'F'); + doc.circle(lineX2, lineY, 0.9, 'F'); + + // Train info sub-row + const rowY = y + routeH; + hairline(doc, margin, rowY, margin + cardW); + doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + padX, rowY + 6, { charSpace: 0.3 }); + doc.setTextColor(...INK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 6); + if (schedule.seatClass) { + doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY); + doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 6, { align: 'right' }); + } + + return y + cardH + 8; } -function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number { - doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); - doc.text('PASSENGER DETAILS', margin, y); +// ─── passenger details ───────────────────────────────────────────────────── + +function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number, pageWidth: number): number { + label(doc, 'Passenger details', margin, y); y += 7; const rows: [string, string][] = [ - ['Full Name', data.passengerName || '—'], - ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], + ['Full name', data.passengerName || '—'], + ['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], ['Nationality', data.nationality || '—'], ]; - if (data.isRoundTrip) { - rows.push(['Outbound Seat', data.outboundSeatNumber || '—']); - rows.push(['Return Seat', data.inboundSeatNumber || '—']); + rows.push(['Outbound seat', data.outboundSeatNumber || '—']); + rows.push(['Return seat', data.inboundSeatNumber || '—']); } else { rows.push(['Seat', data.seatNumber || '—']); } - autoTable(doc, { - startY: y, - body: rows, - theme: 'plain', - styles: { fontSize: 9, cellPadding: 3 }, - columnStyles: { - 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 }, - 1: { textColor: [DARK[0], DARK[1], DARK[2]] }, - }, - alternateRowStyles: { fillColor: [248, 248, 248] }, - margin: { left: margin, right: margin }, + const rowH = 8; + rows.forEach(([k, v], i) => { + const rowY = y + i * rowH; + doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); + doc.text(k.toUpperCase(), margin, rowY + 5, { charSpace: 0.2 }); + doc.setFontSize(9.5); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold'); + doc.text(v, pageWidth - margin, rowY + 5, { align: 'right' }); + if (i < rows.length - 1) hairline(doc, margin, rowY + rowH, pageWidth - margin); }); - return (doc as any).lastAutoTable.finalY + 8; + return y + rows.length * rowH + 6; } +// ─── fare summary ────────────────────────────────────────────────────────── + function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { - doc.setFillColor(248, 248, 248); - doc.rect(margin, y, pageWidth - margin * 2, 20, 'F'); - doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); - doc.text('Fare', margin + 5, y + 7); - doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); - doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' }); - doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold'); - doc.text('✓ PAID', margin + 5, y + 15); - return y + 26; + const cardH = 20; + doc.setFillColor(...BRAND_SOFT); + doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F'); + + const padX = 7; + label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY }); + doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS); + doc.text('✓ PAID', margin + padX, y + 15); + + doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); + + return y + cardH + 8; } +// ─── instructions ────────────────────────────────────────────────────────── + function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number { - doc.setFillColor(252, 211, 77); - doc.rect(margin, y, pageWidth - margin * 2, 18, 'F'); - doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); - doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6); + const cardH = 17; + const barW = 1.4; + + doc.setFillColor(...AMBER_FILL); + doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 2, 2, 'F'); + doc.setFillColor(...AMBER_BORDER); + doc.rect(margin, y, barW, cardH, 'F'); + + const padX = 6; + doc.setFontSize(8); doc.setTextColor(...AMBER_TEXT); doc.setFont('helvetica', 'bold'); + doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 }); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); - doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11); - doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15); - return y + 24; + doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11); + doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15); + + return y + cardH + 6; } +// ─── footer ──────────────────────────────────────────────────────────────── + function drawFooter(doc: jsPDF, createdAt: string): void { const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); - const footerY = pageHeight - 22; + const footerY = pageHeight - 20; - doc.setDrawColor(...LIGHT); - doc.line(15, footerY, pageWidth - 15, footerY); - doc.setFontSize(8); doc.setTextColor(...MED); 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(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); + doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); + doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); + doc.setFontSize(6.5); + doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); } // ─── public API ────────────────────────────────────────────────────────────── @@ -295,25 +362,22 @@ function drawFooter(doc: jsPDF, createdAt: string): void { export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); const pageW = doc.internal.pageSize.getWidth(); - const margin = 15; + const margin = PAGE_MARGIN; let y = await drawHeader(doc, margin); const qrDataUrl = await generateTicketQrDataUrl(data); - // Title - doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); - doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' }); - y += 10; + y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW); - y = drawStatusBadge(doc, data.status, y, pageW); - y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW); + label(doc, 'Journey details', margin, y); + y += 7; y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); if (data.isRoundTrip && data.inboundSchedule) { y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW); } - y = drawPassengerDetails(doc, data, y, margin); + y = drawPassengerDetails(doc, data, y, margin, pageW); y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt);