Fixed voucher qr value

This commit is contained in:
Roba Boru
2026-07-08 22:37:31 +03:00
parent d8b29a2cff
commit 6e7eb460b0
5 changed files with 306 additions and 67 deletions

View File

@@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns';
import { isChild, isFirstChild } from '@/utils/fare-utils';
@@ -45,7 +45,7 @@ export default function ConfirmationPage() {
return {
id: bookingId || '',
pnr: pnr || undefined,
status: 'CONFIRMED',
status: 'PENDING_PAYMENT',
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
};
}
@@ -53,6 +53,10 @@ export default function ConfirmationPage() {
enabled: !!bookingId,
});
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
const isConfirmed = _booking?.status === 'CONFIRMED';
useEffect(() => {
if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true;
@@ -190,14 +194,33 @@ export default function ConfirmationPage() {
{/* Success Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
{isConfirmed ? (
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
) : (
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
<Clock className="w-12 h-12 text-amber-600 dark:text-amber-400" />
</div>
)}
</div>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
{isConfirmed ? (
<>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
</>
) : (
<>
<h1 className="text-4xl font-bold text-amber-600 dark:text-amber-400 mb-2">
Booking received payment pending
</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">
We haven&apos;t confirmed your payment yet. Your tickets will be issued once payment is completed.
</p>
</>
)}
</div>
{/* PNR Card */}
@@ -340,7 +363,9 @@ export default function ConfirmationPage() {
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
<p className="font-semibold text-green-600 dark:text-green-400">{_booking?.status || 'CONFIRMED'}</p>
<p className={`font-semibold ${isConfirmed ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
{_booking?.status || 'PENDING_PAYMENT'}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
<div className="space-y-4">
{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 ticketNumber = isConfirmed
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
: null;
return (
<div key={index} className="card hover:shadow-lg transition-shadow">
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
</div>
<span className="badge badge-success">CONFIRMED</span>
{isConfirmed ? (
<span className="badge badge-success">CONFIRMED</span>
) : (
<span className="badge badge-warning">AWAITING PAYMENT</span>
)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>

View File

@@ -3,15 +3,42 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
import { Suspense } from 'react';
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not
// proof of payment. Poll the backend (which reconciles with the provider) before showing
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
// what the browser shows.
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
if (intent?.status === 'FAILED') return 'FAILED';
} catch {
// transient lookup failure — keep retrying until attempts are exhausted
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
return 'UNKNOWN';
}
function DmoneySuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const { bookingId } = useBookingStore();
const [view, setView] = useState<ViewState>('checking');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// D-Money callback query params (mirrors Telebirr)
@@ -19,30 +46,56 @@ function DmoneySuccessContent() {
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
let cancelled = false;
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
return;
}
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
updateStatus('SUCCEEDED');
setView('succeeded');
setTimeout(() => router.push(target), 1500);
} else if (result === 'FAILED') {
updateStatus('FAILED');
setView('failed');
} else {
// Still not confirmed after polling — don't claim success or failure. Hand off to
// /booking/confirmation, which now reflects the booking's real (pending) status.
setView('unknown');
setTimeout(() => router.push(target), 1500);
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
{view === 'checking' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your D-Money payment.</p>
</>
)}
{status === 'done' && (
{view === 'succeeded' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
@@ -52,15 +105,25 @@ function DmoneySuccessContent() {
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
{view === 'failed' && (
<>
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your D-Money payment was not completed.</p>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
</>
)}
{view === 'unknown' && (
<>
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
We haven&apos;t received final confirmation from D-Money yet. Taking you to your booking status.
</p>
</>
)}
</div>

View File

@@ -3,15 +3,42 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
import { Suspense } from 'react';
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not
// proof of payment. Poll the backend (which reconciles with the provider) before showing
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
// what the browser shows.
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
if (intent?.status === 'FAILED') return 'FAILED';
} catch {
// transient lookup failure — keep retrying until attempts are exhausted
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
return 'UNKNOWN';
}
function TelebirrSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const { bookingId } = useBookingStore();
const [view, setView] = useState<ViewState>('checking');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Telebirr callback query params
@@ -19,30 +46,56 @@ function TelebirrSuccessContent() {
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
let cancelled = false;
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
return;
}
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
updateStatus('SUCCEEDED');
setView('succeeded');
setTimeout(() => router.push(target), 1500);
} else if (result === 'FAILED') {
updateStatus('FAILED');
setView('failed');
} else {
// Still not confirmed after polling — don't claim success or failure. Hand off to
// /booking/confirmation, which now reflects the booking's real (pending) status.
setView('unknown');
setTimeout(() => router.push(target), 1500);
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
{view === 'checking' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Telebirr payment.</p>
</>
)}
{status === 'done' && (
{view === 'succeeded' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
{view === 'failed' && (
<>
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Telebirr payment was not completed.</p>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
</>
)}
{view === 'unknown' && (
<>
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
We haven&apos;t received final confirmation from Telebirr yet. Taking you to your booking status.
</p>
</>
)}
</div>

View File

@@ -3,14 +3,41 @@
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on
// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which
// reconciles with the provider) before showing "Payment Successful".
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
if (intent?.status === 'FAILED') return 'FAILED';
} catch {
// transient lookup failure — keep retrying until attempts are exhausted
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
return 'UNKNOWN';
}
function WaafiSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const { bookingId } = useBookingStore();
const [view, setView] = useState<ViewState>('checking');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Waafi callback query params
const referenceId = searchParams.get('referenceId') || '';
@@ -19,29 +46,56 @@ function WaafiSuccessContent() {
const currency = searchParams.get('currency') || '';
useEffect(() => {
// Actual booking confirmation happens server-side via the provider webhook — this page
// only reflects that back to the user. A Manage Booking payment (paying for an
// already-existing booking) has no in-progress booking-store session to show a
// confirmation from, so it goes back to that booking's detail view instead.
let cancelled = false;
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
setReturnTarget(target);
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
return;
}
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
updateStatus('SUCCEEDED');
setView('succeeded');
setTimeout(() => router.push(target), 1500);
} else if (result === 'FAILED') {
updateStatus('FAILED');
setView('failed');
} else {
// Still not confirmed after polling — don't claim success or failure. Hand off to
// /booking/confirmation, which now reflects the booking's real (pending) status.
setView('unknown');
setTimeout(() => router.push(target), 1500);
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
{view === 'checking' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Waafi payment.</p>
</>
)}
{status === 'done' && (
{view === 'succeeded' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
@@ -54,6 +108,27 @@ function WaafiSuccessContent() {
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{view === 'failed' && (
<>
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Waafi payment was not completed.</p>
<button onClick={() => router.push(returnTarget)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
</>
)}
{view === 'unknown' && (
<>
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
We haven&apos;t received final confirmation from Waafi yet. Taking you to your booking status.
</p>
</>
)}
</div>
</div>
);

View File

@@ -52,11 +52,14 @@ const PAGE_MARGIN = 18;
// Encodes everything a gate scanner needs to verify this specific ticket without
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
// departure time and fare. Kept as compact JSON so any generic QR reader can parse it.
// Field names (`ref`/`ticketNumber`) must match what the backoffice boarding scanner and
// tickets.service.ts's scanAndBoard() read from the QR payload — see apps/edr-passenger-api/
// src/modules/tickets/tickets.service.ts.
function buildTicketQrPayload(data: PassengerVoucherData): string {
return JSON.stringify({
type: 'EDR_TICKET',
pnr: data.bookingRef,
ticket: data.ticketNumber,
ref: data.bookingRef,
ticketNumber: data.ticketNumber,
passenger: data.passengerName,
status: data.status,
train: data.outboundSchedule.trainNumber,
@@ -165,6 +168,10 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
const qrSize = 22;
const qrPad = 2.5;
const cardSize = qrSize + qrPad * 2;
const qrGap = 3;
// The QR box is anchored to the right edge of the card — reserve that space so the
// status pill (also right-anchored) never draws underneath/over it.
const qrCardX = pageWidth - margin - cardSize - qrGap;
doc.setFillColor(...SURFACE);
doc.setDrawColor(...HAIRLINE);
@@ -172,7 +179,8 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
const padX = 7;
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
const pillRightX = qrDataUrl ? qrCardX - qrGap : pageWidth - margin - padX;
drawStatusPill(doc, status, pillRightX, y + 5.5, 'right');
label(doc, 'Booking reference', margin + padX, y + 12);
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
@@ -183,13 +191,12 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
if (qrDataUrl) {
const cardX = pageWidth - margin - cardSize - 3;
const cardY = y + (cardH - cardSize) / 2;
doc.setFillColor(255, 255, 255);
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);
doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD');
doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize);
}
return y + cardH + 10;