mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user