Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI

This commit is contained in:
yaschalew
2026-07-11 11:35:11 +03:00
8 changed files with 430 additions and 88 deletions

View File

@@ -441,6 +441,36 @@ export class BookingsController {
return this.service.checkBookingUsage(id);
}
@Get('by-phone')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'Find bookings by phone number (no auth required)',
description: `Returns all bookings where the contact phone matches the provided number.
Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX).
Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.`
})
@ApiQuery({ name: 'phone', required: true, description: 'Phone number in local (09…) or international (+251…) format' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiResponse({ status: 200, description: 'Paginated list of bookings for this phone number' })
@ApiResponse({ status: 400, description: 'Phone number missing or invalid' })
findByPhone(
@Query('phone') phone?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!phone?.trim()) throw new BadRequestException('Phone number is required');
const digits = phone.replace(/[^\d]/g, '');
if (digits.length < 7) throw new BadRequestException('Phone number is too short');
return this.service.findByPhone(phone.trim(), {
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
}
@Get(':bookingRef')
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -39,6 +39,37 @@ function resolvePackageRoundTripTotal(
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
}
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
const digits = stripped.replace(/^\+/, '');
if (digits.length < 7) return [];
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 09XXXXXXXX
variants.add('0' + digits.slice(3));
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped);
variants.add('0' + digits.slice(3));
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX
variants.add('+251' + digits.slice(1));
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
}
return [...variants];
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
@@ -145,6 +176,70 @@ export class BookingsService {
};
}
async findByPhone(rawPhone: string, filters: BookingFilters = {}) {
const variants = normalizePhoneVariants(rawPhone);
if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } };
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
],
};
if (status) where.status = status;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
seats: { select: { id: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
@@ -1516,7 +1611,12 @@ export class BookingsService {
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
? {
method: (pkgBooking as any).paymentIntent.method,
status: (pkgBooking as any).paymentIntent.status,
amountMinor: (pkgBooking as any).paymentIntent.amountMinor,
currency: (pkgBooking as any).paymentIntent.currency,
}
: undefined,
tickets: [],
};
@@ -1556,7 +1656,14 @@ export class BookingsService {
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
},
})),
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
}
: undefined,
// One ticket per passenger — matched on the frontend by passengerName, not array
// position, since tickets are grouped/created independently of the passengers array.
tickets: (booking as any).tickets?.map((t: any) => ({

View File

@@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store';
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 { useEffect, useState } from 'react';
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns';
import { isChild, isFirstChild } from '@/utils/fare-utils';
@@ -19,6 +19,14 @@ type BookingWithTicket = {
totalMinor?: number;
createdAt?: string;
paymentMethod?: string;
// The actual settled amount/currency for this booking's payment — authoritative over any
// client-side session state, since it reflects what was really charged server-side.
payment?: {
method?: string;
status?: string;
amountMinor?: number;
currency?: string;
};
// One ticket per passenger — match by passengerName, not array position (see
// bookings.service.ts's getByRef).
tickets?: Array<{
@@ -37,7 +45,6 @@ export default function ConfirmationPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(false);
// Warms the code-split voucher module ahead of the click so the handler's own
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
@@ -66,22 +73,11 @@ export default function ConfirmationPage() {
// 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).
// Ticket generation itself is never triggered from this page — the payment webhook
// generates it server-side (for every payment method, wallet included); this page only
// ever fetches and displays whatever the booking query above already returns.
const isConfirmed = _booking?.status === 'CONFIRMED';
useEffect(() => {
if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true;
// Only generate ticket if booking is already CONFIRMED (e.g. wallet payment)
// For other payment methods, ticket is generated by the payment webhook after payment completes
apiClient.get(`/bookings/${bookingId}`).then((data: any) => {
if (data?.status === 'CONFIRMED') {
apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {});
}
}).catch(() => {});
}
}, [bookingId]);
const copyPNR = () => {
if (pnr) {
navigator.clipboard.writeText(pnr);
@@ -105,15 +101,17 @@ export default function ConfirmationPage() {
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// Prefer the amount/currency actually confirmed for the selected payment option;
// only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
const voucherCurrency = 'ETB';
// The server-confirmed settled amount/currency (what was actually charged) is
// authoritative — prefer it over the ETB booking fare once it's available.
const settledAmountMinor = _booking?.payment?.amountMinor;
const settledCurrency = _booking?.payment?.currency;
const voucherCurrency = settledCurrency || 'ETB';
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || 'CONFIRMED';
// Compute per-passenger fares using the same logic as the review/payment pages.
// reviewedPassengerFares is the authoritative source; rebuild from package context
// as a fallback so free children always show ETB 0.00 on their voucher.
// Compute per-passenger fares (in ETB) using the same logic as the review/payment
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
// context as a fallback so free children always show 0 on their voucher.
const { packageTierPriceMinor } = useBookingStore.getState();
const isPackageBooking = packageTierPriceMinor != null;
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
@@ -121,7 +119,7 @@ export default function ConfirmationPage() {
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare;
const getVoucherFare = (idx: number): number => {
const getEtbFare = (idx: number): number => {
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
if (isPackageBooking) {
const isPkgChild = idx >= adultCount;
@@ -133,6 +131,17 @@ export default function ConfirmationPage() {
return Math.round(totalFare / passengers.length);
};
// Real conversion happened (payment settled in something other than ETB) — scale each
// passenger's ETB fare proportionally into the settled currency, rather than showing
// ETB-denominated numbers next to a foreign currency label.
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
const etbTotal = etbFares.reduce((sum, f) => sum + f, 0);
const needsConversion = settledAmountMinor != null && settledCurrency && settledCurrency !== 'ETB' && etbTotal > 0;
const getVoucherFare = (idx: number): number => {
if (!needsConversion) return etbFares[idx];
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
};
const outbound = {
trainNumber: activeSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express',
@@ -390,6 +399,11 @@ export default function ConfirmationPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{(() => {
// The server-confirmed settled amount is authoritative — prefer it over
// any client-side session state, which can go stale (e.g. after a refresh).
if (_booking?.payment?.amountMinor != null) {
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
}
if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;

View File

@@ -633,6 +633,20 @@ function BookingDetailContent() {
</button>
</div>
{isConfirmed && (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Total paid:{' '}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{booking?.payment?.amountMinor != null
? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
</span>
{booking?.payment?.method && (
<span className="text-gray-500 dark:text-gray-400"> via {booking.payment.method}</span>
)}
</div>
)}
<div className="flex flex-wrap gap-3 justify-center mt-6">
{isConfirmed && (
<>

View File

@@ -1,28 +1,99 @@
"use client";
import { Search } from "lucide-react";
import { Search, Phone, Ticket, ChevronRight, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiClient } from "@/lib/api-client";
import { format } from "date-fns";
type SearchMode = "pnr" | "phone";
interface BookingListItem {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
bookingType: string;
createdAt: string;
schedule: {
originStation: { name: string; city?: string };
destinationStation: { name: string; city?: string };
departureAt: string;
};
payment?: { method: string; status: string };
seatCount: number;
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" },
REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" },
};
export default function BookingLookupPage() {
const router = useRouter();
const [mode, setMode] = useState<SearchMode>("pnr");
// PNR mode state
const [bookingRef, setBookingRef] = useState("");
// Phone mode state
const [phone, setPhone] = useState("");
const [phoneResults, setPhoneResults] = useState<BookingListItem[] | null>(null);
const [phoneLoading, setPhoneLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
// ── PNR submit ───────────────────────────────────────────────────────────
const handlePnrSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = bookingRef.trim().toUpperCase();
if (!trimmed) {
setError("Please enter a booking reference");
return;
}
if (!trimmed) { setError("Please enter a booking reference"); return; }
router.push(`/booking/detail?ref=${trimmed}`);
};
// ── Phone submit ─────────────────────────────────────────────────────────
const handlePhoneSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = phone.trim();
if (!trimmed) { setError("Please enter your phone number"); return; }
const digits = trimmed.replace(/[^\d]/g, "");
if (digits.length < 7) { setError("Please enter a valid phone number"); return; }
setError("");
setPhoneLoading(true);
setPhoneResults(null);
try {
const resp: any = await apiClient.get(`/bookings/by-phone?phone=${encodeURIComponent(trimmed)}`);
const items: BookingListItem[] = (resp as any)?.data?.items ?? (resp as any)?.items ?? [];
setPhoneResults(items);
if (items.length === 0) setError("No bookings found for this phone number");
} catch {
setError("Could not look up bookings. Please check your number and try again.");
} finally {
setPhoneLoading(false);
}
};
const switchMode = (next: SearchMode) => {
setMode(next);
setError("");
setPhoneResults(null);
setBookingRef("");
setPhone("");
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-900">
<div className="w-full max-w-md">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-[rgb(20,113,76)] bg-opacity-10 rounded-full mb-4">
<Search className="w-8 h-8 text-[rgb(20,113,76)]" />
@@ -30,39 +101,145 @@ export default function BookingLookupPage() {
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Find Your Booking
</h1>
<p className="text-gray-600 dark:text-gray-400">
Enter your booking reference (PNR) to view details
<p className="text-gray-600 dark:text-gray-400 text-sm">
Search by booking reference or phone number
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Booking Reference (PNR)
</label>
<input
type="text"
value={bookingRef}
onChange={(e) => {
setBookingRef(e.target.value.toUpperCase());
setError("");
}}
placeholder="Enter your PNR"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono"
/>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
{/* Mode tabs */}
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 mb-6 overflow-hidden">
<button
type="submit"
className="w-full bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
onClick={() => switchMode("pnr")}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-medium transition-colors ${
mode === "pnr"
? "bg-[rgb(20,113,76)] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<Search className="w-5 h-5" />
Search Booking
<Ticket className="w-4 h-4" />
Booking Ref (PNR)
</button>
</form>
<button
onClick={() => switchMode("phone")}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-medium transition-colors ${
mode === "phone"
? "bg-[rgb(20,113,76)] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<Phone className="w-4 h-4" />
Phone Number
</button>
</div>
{/* ── PNR form ── */}
{mode === "pnr" && (
<form onSubmit={handlePnrSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Booking Reference (PNR)
</label>
<input
type="text"
value={bookingRef}
onChange={(e) => { setBookingRef(e.target.value.toUpperCase()); setError(""); }}
placeholder="e.g. ABCXYZ"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono uppercase tracking-widest"
/>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
<button
type="submit"
className="w-full bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Search className="w-5 h-5" />
Search Booking
</button>
</form>
)}
{/* ── Phone form ── */}
{mode === "phone" && (
<>
<form onSubmit={handlePhoneSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Phone Number
</label>
<input
type="tel"
value={phone}
onChange={(e) => { setPhone(e.target.value); setError(""); setPhoneResults(null); }}
placeholder="Enter phone number"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter the phone number you used when booking
</p>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
<button
type="submit"
disabled={phoneLoading}
className="w-full bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
{phoneLoading ? (
<><Loader2 className="w-5 h-5 animate-spin" /> Searching</>
) : (
<><Search className="w-5 h-5" /> Find Bookings</>
)}
</button>
</form>
{/* Results list */}
{phoneResults !== null && phoneResults.length > 0 && (
<div className="mt-6 space-y-3">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{phoneResults.length} booking{phoneResults.length !== 1 ? "s" : ""} found select one to view details:
</p>
{phoneResults.map((b) => {
const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" };
const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
return (
<button
key={b.id}
onClick={() => router.push(`/booking/detail?ref=${b.bookingRef}`)}
className="w-full text-left border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-[rgb(20,113,76)] hover:bg-green-50 dark:hover:bg-green-900/10 transition-colors group"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono font-bold text-gray-900 dark:text-white tracking-wider">
{b.bookingRef}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.className}`}>
{statusInfo.label}
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">
{b.schedule.originStation.name} {b.schedule.destinationStation.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{format(new Date(b.schedule.departureAt), "dd MMM yyyy, HH:mm")}
{" · "}
{b.adultCount} adult{b.adultCount !== 1 ? "s" : ""}
{b.childCount > 0 && `, ${b.childCount} child${b.childCount !== 1 ? "ren" : ""}`}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<span className="text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
{amountEtb} ETB
</span>
<ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-[rgb(20,113,76)] transition-colors" />
</div>
</div>
</button>
);
})}
</div>
)}
</>
)}
</div>
</div>
</div>

View File

@@ -465,8 +465,10 @@ function validatePhone(phone: string, nationality: string): string | null {
if (!normalized) return 'Phone number is required';
const nat = getPhoneNat(nationality);
if (nat === 'ETHIOPIAN') {
if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
// Only Ethio Telecom (0/+2519...) and Safaricom Ethiopia (0/+2517...) mobile ranges —
// other prefixes (e.g. landlines, unallocated blocks) are rejected.
if (/^(\+251[79]\d{8}|0[79]\d{8})$/.test(normalized)) return null;
return 'Enter a valid Ethio Telecom or Safaricom Ethiopia number (e.g., +251912345678 or 0712345678)';
}
if (nat === 'DJIBOUTIAN') {
if (/^\+253\d{8}$/.test(normalized)) return null;

View File

@@ -313,19 +313,15 @@ export default function ReviewPage() {
}
// Build booking request for authenticated users
// Package bookings only: free children (first child per adult) don't go through
// seat selection and have no seatId, so they're excluded here — the backend derives
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
// (including the free child, who still gets a real seatId and a $0 fare handled by
// the backend), so they must stay in the array or that passenger — and their
// ticket/seat/childCount — silently never gets created.
const bookingPassengers = passengers.filter((_p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return true;
});
// Free children (first child per adult) never go through seat selection and have no
// seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's
// seatEligibility: "First adultCount children are free (no seat)... same rule applies"
// for regular bookings too). Submitting one anyway sends seatId: undefined, which the
// backend's `seat: { connect: { id } }` rejects — hence excluding them here for both
// cases. (Their absence from adultCount/childCount on the confirmed booking is a
// separate, backend-side gap — not something the frontend can paper over by sending
// an unseated passenger.)
const bookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i)));
bookingData = {
passengerId: passengerId,
@@ -375,19 +371,12 @@ export default function ReviewPage() {
if (priceTierId) bookingData.priceTierId = priceTierId;
} else {
// For guests: send full passenger details array
// Package bookings only: free children (first child per adult) don't go through
// seat selection and have no seatId, so they're excluded here — the backend derives
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
// (including the free child, who still gets a real seatId and a $0 fare handled by
// the backend), so they must stay in the array or that passenger — and their
// ticket/seat/childCount — silently never gets created.
const guestBookingPassengers = passengers.filter((_p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return true;
});
// Free children (first child per adult) never go through seat selection and have no
// seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's
// seatEligibility comment). Submitting one anyway sends seatId: undefined, which the
// backend's `seat: { connect: { id } }` rejects — hence excluding them here for both
// cases.
const guestBookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i)));
bookingData = {
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,

View File

@@ -426,9 +426,18 @@ interface VoucherData {
// One ticket per passenger, matched below by passengerName — see bookings.service.ts's
// getByRef(). Optional/absent falls back to a client-generated placeholder number.
tickets?: Array<{ passengerName?: string; barcodePayload?: string }>;
// The actual settled amount/currency for this booking's payment — preferred over the
// ETB booking total once available, since it reflects what was really charged.
payment?: { amountMinor?: number; currency?: string };
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
// them — a setTimeout delay here would push later saves outside the click's synchronous
// user-activation window and risk iOS Safari silently blocking them. The awaited work
@@ -450,8 +459,8 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
status: booking.status,
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
isRoundTrip: false,
fareMinor: Math.round(booking.totalMinor / booking.passengers.length),
currency: booking.currency,
fareMinor: Math.round(totalForSplit / booking.passengers.length),
currency: voucherCurrency,
createdAt: booking.createdAt,
});
}