mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Update manage booking
This commit is contained in:
@@ -285,7 +285,11 @@ export class NotificationsService {
|
|||||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||||
const amount = this.formatAmount(booking ?? payload.booking);
|
const amount = this.formatAmount(booking ?? payload.booking);
|
||||||
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
|
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.
|
// IN_APP — always created.
|
||||||
await this.createInAppNotification(
|
await this.createInAppNotification(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||||
|
import { formatFare } from '@/utils/fare-utils';
|
||||||
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||||
import QRCode from 'qrcode.react';
|
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<string, { fullName: string; category: string; outboundFareMinor: number; returnFareMinor: number }>();
|
||||||
|
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
|
// 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
|
// 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.
|
// a note confirming what will actually be charged once a payment method is selected.
|
||||||
@@ -239,19 +268,39 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
|
||||||
{(booking.passengers || []).map((passenger: any, idx: number) => (
|
{farePassengers.map((passenger: any, idx: number) => {
|
||||||
<div key={idx} className="flex justify-between border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
const isChildPassenger = passenger.category === 'CHILD';
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0;
|
||||||
{passenger.fullName || `Passenger ${idx + 1}`}
|
return (
|
||||||
{passenger.category === 'CHILD' && (
|
<div key={idx} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||||
<span className="text-xs font-semibold ml-1 text-blue-600">(CHILD)</span>
|
<div className="flex justify-between mb-0.5">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
||||||
|
{passenger.fullName || `Passenger ${idx + 1}`}
|
||||||
|
{isChildPassenger && (
|
||||||
|
<span className={`text-xs font-semibold ml-1 ${isFreeChild ? 'text-green-600' : 'text-blue-600'}`}>
|
||||||
|
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatFare(passenger.fareMinor ?? 0, displayCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isRoundTripBooking && !isFreeChild && (
|
||||||
|
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Outbound</span>
|
||||||
|
<span>{formatFare(passenger.outboundFareMinor ?? 0, displayCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Return</span>
|
||||||
|
<span>{formatFare(passenger.returnFareMinor ?? 0, displayCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
);
|
||||||
{displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)}
|
})}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
@@ -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
|
// hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a
|
||||||
// deliberate deselect/re-pick afterwards.
|
// deliberate deselect/re-pick afterwards.
|
||||||
const restoredLegRef = useRef<string | null>(null);
|
const restoredLegRef = useRef<string | null>(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<Set<number>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
|
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
|
||||||
if (restoredLegRef.current === legKey) return;
|
if (restoredLegRef.current === legKey) return;
|
||||||
@@ -322,6 +327,7 @@ export default function SeatsPage() {
|
|||||||
});
|
});
|
||||||
if (Object.keys(restored).length > 0) {
|
if (Object.keys(restored).length > 0) {
|
||||||
setPassengerSeatMap(restored);
|
setPassengerSeatMap(restored);
|
||||||
|
restoredIndicesRef.current = new Set(Object.keys(restored).map(Number));
|
||||||
}
|
}
|
||||||
}, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]);
|
}, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]);
|
||||||
|
|
||||||
@@ -471,6 +477,21 @@ export default function SeatsPage() {
|
|||||||
setPendingCoachLabel(coach.label);
|
setPendingCoachLabel(coach.label);
|
||||||
setShowCoachPreview(false);
|
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
|
// 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.
|
// so the review page totals reflect the switched coach type.
|
||||||
if (isPackageBooking && packageId && newFare != null) {
|
if (isPackageBooking && packageId && newFare != null) {
|
||||||
@@ -786,6 +807,8 @@ export default function SeatsPage() {
|
|||||||
next[activePassengerIndex] = seatId;
|
next[activePassengerIndex] = seatId;
|
||||||
}
|
}
|
||||||
setPassengerSeatMap(next);
|
setPassengerSeatMap(next);
|
||||||
|
// Whatever happens now is a deliberate pick — no longer just a restored hold.
|
||||||
|
restoredIndicesRef.current.delete(activePassengerIndex);
|
||||||
|
|
||||||
if (!isDeselecting) {
|
if (!isDeselecting) {
|
||||||
// Move on to the next passenger who still needs a seat — one passenger at a time
|
// 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;
|
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) {
|
if (isDeselecting) {
|
||||||
|
restoredIndicesRef.current.delete(activePassengerIndex);
|
||||||
commitSeatAssignment(seatId);
|
commitSeatAssignment(seatId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare
|
// A fare-change warning only makes sense when there's an actual prior selection to
|
||||||
// differs from what the user originally selected (e.g. after switching coach type
|
// change FROM — either this same passenger swapping their own pick for a
|
||||||
// via the preview, or just picking a pricier berth) — or from another passenger's
|
// differently-priced one, or picking a seat priced differently from another
|
||||||
// already-selected seat — needs a heads-up before it's applied.
|
// 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 newSeat = validSeats?.find((s: any) => s.id === seatId);
|
||||||
const newFare = newSeat ? getSeatFare(newSeat) : null;
|
const newFare = newSeat ? getSeatFare(newSeat) : null;
|
||||||
|
|
||||||
if (newFare != null) {
|
if (newFare != null) {
|
||||||
let referenceFare: number | null = null;
|
let referenceFare: number | null = null;
|
||||||
let referenceLabel = "the fare you originally selected";
|
let referenceLabel = "your previously selected seat";
|
||||||
|
|
||||||
if (isPackageBooking) {
|
if (isPackageBooking) {
|
||||||
// For package bookings, compare against the stored tier price (per leg).
|
// For package bookings, compare against the stored tier price (per leg).
|
||||||
@@ -829,22 +860,28 @@ export default function SeatsPage() {
|
|||||||
if (pkgLegFare != null && newFare !== pkgLegFare) {
|
if (pkgLegFare != null && newFare !== pkgLegFare) {
|
||||||
referenceFare = 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 {
|
} else {
|
||||||
if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
|
// First pick for this passenger — only compare against another passenger's
|
||||||
referenceFare = originalFareForCurrentLeg;
|
// already-selected seat in this session.
|
||||||
} else {
|
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
|
||||||
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
|
if (Number(idx) === activePassengerIndex) return false;
|
||||||
if (Number(idx) === activePassengerIndex) return false;
|
const otherSeat = validSeats?.find((s: any) => s.id === sid);
|
||||||
const otherSeat = validSeats?.find((s: any) => s.id === sid);
|
const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
|
||||||
const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
|
return otherFare != null && otherFare !== newFare;
|
||||||
return otherFare != null && otherFare !== newFare;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (differingEntry) {
|
if (differingEntry) {
|
||||||
const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
|
const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
|
||||||
referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
|
referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
|
||||||
referenceLabel = "another already-selected seat";
|
referenceLabel = "another already-selected seat";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +935,7 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
commitSeatAssignment(seatId);
|
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 =
|
const allSeatsAssigned =
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
interface ScheduleInfo {
|
interface ScheduleInfo {
|
||||||
@@ -30,12 +29,24 @@ interface PassengerVoucherData {
|
|||||||
createdAt: string;
|
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 BRAND = [20, 113, 76] as const; // brand green — accents only
|
||||||
const DARK = [51, 51, 51] as const;
|
const BRAND_SOFT = [235, 245, 240] as const; // pale green tint for subtle fills
|
||||||
const MED = [102, 102, 102] as const;
|
const INK = [24, 28, 33] as const; // headings, high-emphasis text
|
||||||
const LIGHT = [200, 200, 200] as const;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── QR code ───────────────────────────────────────────────────────────────
|
||||||
// Encodes everything a gate scanner needs to verify this specific ticket without
|
// Encodes everything a gate scanner needs to verify this specific ticket without
|
||||||
@@ -63,7 +74,7 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
|
|||||||
width: 240,
|
width: 240,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
color: { dark: '#0f172a', light: '#ffffff' },
|
color: { dark: '#181c21', light: '#ffffff' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate ticket QR code:', error);
|
console.error('Failed to generate ticket QR code:', error);
|
||||||
@@ -71,11 +82,30 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── shared drawing helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function label(doc: jsPDF, text: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center'; color?: readonly [number, number, number] }): void {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
const c = opts?.color ?? MUTED;
|
||||||
|
doc.setTextColor(c[0], c[1], c[2]);
|
||||||
|
doc.text(text.toUpperCase(), x, y, { align: opts?.align ?? 'left', charSpace: 0.3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
|
||||||
|
doc.setDrawColor(...HAIRLINE);
|
||||||
|
doc.setLineWidth(0.25);
|
||||||
|
doc.line(x1, y, x2, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── header ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const bandHeight = 24;
|
||||||
|
|
||||||
doc.setFillColor(...PRIMARY);
|
doc.setFillColor(...BRAND);
|
||||||
doc.rect(0, 0, pageWidth, 30, 'F');
|
doc.rect(0, 0, pageWidth, bandHeight, 'F');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logoImg = await fetch('/edr-logo.png');
|
const logoImg = await fetch('/edr-logo.png');
|
||||||
@@ -87,206 +117,243 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
|||||||
});
|
});
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
|
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
|
||||||
const logoH = 18;
|
const logoH = 13;
|
||||||
const logoW = (img.width / img.height) * logoH;
|
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.setTextColor(255, 255, 255);
|
||||||
doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
doc.setFontSize(13); doc.setFont('helvetica', 'bold');
|
||||||
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
|
doc.text('ETHIO-DJIBOUTI RAILWAY', textX, bandHeight / 2 - 1);
|
||||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
|
||||||
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
|
doc.setTextColor(230, 240, 236);
|
||||||
|
doc.text('E-TICKET · BOARDING VOUCHER', textX, bandHeight / 2 + 5, { charSpace: 0.4 });
|
||||||
} catch {
|
} catch {
|
||||||
doc.setTextColor(255, 255, 255);
|
doc.setTextColor(255, 255, 255);
|
||||||
doc.setFontSize(22); doc.setFont('helvetica', 'bold');
|
doc.setFontSize(15); doc.setFont('helvetica', 'bold');
|
||||||
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
|
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, bandHeight / 2 - 1, { align: 'center' });
|
||||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
|
||||||
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
|
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 {
|
// ─── status pill ───────────────────────────────────────────────────────────
|
||||||
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
|
|
||||||
const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
|
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.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.setTextColor(255, 255, 255);
|
||||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
doc.text(text.toUpperCase(), pillX + pillW / 2, y + pillH / 2 + 1.4, { align: 'center', charSpace: 0.3 });
|
||||||
doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
|
|
||||||
return y + 12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
|
// ─── hero card: PNR + ticket number + QR ──────────────────────────────────
|
||||||
const boxHeight = 32;
|
|
||||||
const qrSize = 24;
|
|
||||||
const qrPad = 3;
|
|
||||||
const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0;
|
|
||||||
|
|
||||||
doc.setFillColor(245, 245, 245);
|
function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, status: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
|
||||||
doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F');
|
const cardH = 32;
|
||||||
|
const qrSize = 22;
|
||||||
|
const qrPad = 2.5;
|
||||||
|
const cardSize = qrSize + qrPad * 2;
|
||||||
|
|
||||||
// Booking reference (top-left)
|
doc.setFillColor(...SURFACE);
|
||||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
doc.setDrawColor(...HAIRLINE);
|
||||||
doc.text('BOOKING REFERENCE', margin + 5, y + 8);
|
doc.setLineWidth(0.3);
|
||||||
doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
|
||||||
doc.text(bookingRef, margin + 5, y + 18);
|
|
||||||
|
|
||||||
// Ticket number, stacked below — leaves room on the right for the QR block
|
const padX = 7;
|
||||||
const textRightBound = pageWidth - margin - qrBlockWidth - 5;
|
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
|
||||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' });
|
label(doc, 'Booking reference', margin + padX, y + 12);
|
||||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
|
||||||
doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' });
|
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) {
|
if (qrDataUrl) {
|
||||||
const cardSize = qrSize + qrPad * 2;
|
|
||||||
const cardX = pageWidth - margin - cardSize - 3;
|
const cardX = pageWidth - margin - cardSize - 3;
|
||||||
const cardY = y + (boxHeight - cardSize) / 2;
|
const cardY = y + (cardH - cardSize) / 2;
|
||||||
doc.setFillColor(255, 255, 255);
|
doc.setFillColor(255, 255, 255);
|
||||||
doc.setDrawColor(...LIGHT);
|
doc.setDrawColor(...HAIRLINE);
|
||||||
doc.setLineWidth(0.4);
|
doc.setLineWidth(0.3);
|
||||||
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||||
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
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 {
|
// ─── journey card ──────────────────────────────────────────────────────────
|
||||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
|
|
||||||
y += 7;
|
|
||||||
|
|
||||||
doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
|
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number {
|
||||||
doc.rect(margin, y, pageWidth - margin * 2, 40);
|
const cardW = pageWidth - margin * 2;
|
||||||
|
const routeH = 30;
|
||||||
|
const trainRowH = 9;
|
||||||
|
const cardH = routeH + trainRowH;
|
||||||
|
|
||||||
// Origin
|
doc.setDrawColor(...HAIRLINE);
|
||||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
doc.setLineWidth(0.3);
|
||||||
doc.text('FROM', margin + 5, y + 6);
|
doc.roundedRect(margin, y, cardW, cardH, 3, 3, 'D');
|
||||||
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);
|
|
||||||
|
|
||||||
const dep = new Date(schedule.departureAt);
|
if (legLabel) {
|
||||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
doc.setFillColor(...BRAND);
|
||||||
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33);
|
doc.roundedRect(margin + 6, y - 3, doc.getTextWidth(legLabel.toUpperCase()) + 7, 6, 3, 3, 'F');
|
||||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
doc.setTextColor(255, 255, 255); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
|
||||||
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
|
doc.text(legLabel.toUpperCase(), margin + 6 + (doc.getTextWidth(legLabel.toUpperCase()) + 7) / 2, y, { align: 'center', charSpace: 0.3 });
|
||||||
|
|
||||||
// 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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
// ─── passenger details ─────────────────────────────────────────────────────
|
||||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('PASSENGER DETAILS', margin, y);
|
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number, pageWidth: number): number {
|
||||||
|
label(doc, 'Passenger details', margin, y);
|
||||||
y += 7;
|
y += 7;
|
||||||
|
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
['Full Name', data.passengerName || '—'],
|
['Full name', data.passengerName || '—'],
|
||||||
['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
|
['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
|
||||||
['Nationality', data.nationality || '—'],
|
['Nationality', data.nationality || '—'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (data.isRoundTrip) {
|
if (data.isRoundTrip) {
|
||||||
rows.push(['Outbound Seat', data.outboundSeatNumber || '—']);
|
rows.push(['Outbound seat', data.outboundSeatNumber || '—']);
|
||||||
rows.push(['Return Seat', data.inboundSeatNumber || '—']);
|
rows.push(['Return seat', data.inboundSeatNumber || '—']);
|
||||||
} else {
|
} else {
|
||||||
rows.push(['Seat', data.seatNumber || '—']);
|
rows.push(['Seat', data.seatNumber || '—']);
|
||||||
}
|
}
|
||||||
|
|
||||||
autoTable(doc, {
|
const rowH = 8;
|
||||||
startY: y,
|
rows.forEach(([k, v], i) => {
|
||||||
body: rows,
|
const rowY = y + i * rowH;
|
||||||
theme: 'plain',
|
doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
||||||
styles: { fontSize: 9, cellPadding: 3 },
|
doc.text(k.toUpperCase(), margin, rowY + 5, { charSpace: 0.2 });
|
||||||
columnStyles: {
|
doc.setFontSize(9.5); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
|
||||||
0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
|
doc.text(v, pageWidth - margin, rowY + 5, { align: 'right' });
|
||||||
1: { textColor: [DARK[0], DARK[1], DARK[2]] },
|
if (i < rows.length - 1) hairline(doc, margin, rowY + rowH, pageWidth - margin);
|
||||||
},
|
|
||||||
alternateRowStyles: { fillColor: [248, 248, 248] },
|
|
||||||
margin: { left: margin, right: 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 {
|
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
|
||||||
doc.setFillColor(248, 248, 248);
|
const cardH = 20;
|
||||||
doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
|
doc.setFillColor(...BRAND_SOFT);
|
||||||
doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
||||||
doc.text('Fare', margin + 5, y + 7);
|
|
||||||
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
const padX = 7;
|
||||||
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
|
label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY });
|
||||||
doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
|
||||||
doc.text('✓ PAID', margin + 5, y + 15);
|
doc.text('✓ PAID', margin + padX, y + 15);
|
||||||
return y + 26;
|
|
||||||
|
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 {
|
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
|
||||||
doc.setFillColor(252, 211, 77);
|
const cardH = 17;
|
||||||
doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
|
const barW = 1.4;
|
||||||
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
|
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.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
||||||
doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11);
|
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
|
||||||
doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
|
doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
|
||||||
return y + 24;
|
|
||||||
|
return y + cardH + 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── footer ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function drawFooter(doc: jsPDF, createdAt: string): void {
|
function drawFooter(doc: jsPDF, createdAt: string): void {
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
const pageHeight = doc.internal.pageSize.getHeight();
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
const footerY = pageHeight - 22;
|
const footerY = pageHeight - 20;
|
||||||
|
|
||||||
doc.setDrawColor(...LIGHT);
|
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
|
||||||
doc.line(15, footerY, pageWidth - 15, footerY);
|
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
||||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
||||||
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
|
doc.setFontSize(6.5);
|
||||||
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
|
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── public API ──────────────────────────────────────────────────────────────
|
// ─── public API ──────────────────────────────────────────────────────────────
|
||||||
@@ -295,25 +362,22 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
|
|||||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
||||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
const margin = 15;
|
const margin = PAGE_MARGIN;
|
||||||
|
|
||||||
let y = await drawHeader(doc, margin);
|
let y = await drawHeader(doc, margin);
|
||||||
const qrDataUrl = await generateTicketQrDataUrl(data);
|
const qrDataUrl = await generateTicketQrDataUrl(data);
|
||||||
|
|
||||||
// Title
|
y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW);
|
||||||
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' });
|
|
||||||
y += 10;
|
|
||||||
|
|
||||||
y = drawStatusBadge(doc, data.status, y, pageW);
|
label(doc, 'Journey details', margin, y);
|
||||||
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW);
|
y += 7;
|
||||||
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
|
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
|
||||||
|
|
||||||
if (data.isRoundTrip && data.inboundSchedule) {
|
if (data.isRoundTrip && data.inboundSchedule) {
|
||||||
y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
|
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);
|
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
|
||||||
drawInstructions(doc, y, margin, pageW);
|
drawInstructions(doc, y, margin, pageW);
|
||||||
drawFooter(doc, data.createdAt);
|
drawFooter(doc, data.createdAt);
|
||||||
|
|||||||
Reference in New Issue
Block a user