mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +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);
|
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')
|
@Get(':bookingRef')
|
||||||
@SetMetadata('isPublic', true)
|
@SetMetadata('isPublic', true)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -39,6 +39,37 @@ function resolvePackageRoundTripTotal(
|
|||||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
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 {
|
function calculateAge(dateOfBirth: Date): number {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
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 = {}) {
|
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
|
||||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
@@ -1516,7 +1611,12 @@ export class BookingsService {
|
|||||||
seat: null,
|
seat: null,
|
||||||
})),
|
})),
|
||||||
payment: (pkgBooking as any).paymentIntent
|
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,
|
: undefined,
|
||||||
tickets: [],
|
tickets: [],
|
||||||
};
|
};
|
||||||
@@ -1556,7 +1656,14 @@ export class BookingsService {
|
|||||||
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
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
|
// One ticket per passenger — matched on the frontend by passengerName, not array
|
||||||
// position, since tickets are grouped/created independently of the passengers array.
|
// position, since tickets are grouped/created independently of the passengers array.
|
||||||
tickets: (booking as any).tickets?.map((t: any) => ({
|
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 { usePaymentStore } from '@/lib/payment-store';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
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 { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
||||||
@@ -19,6 +19,14 @@ type BookingWithTicket = {
|
|||||||
totalMinor?: number;
|
totalMinor?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
paymentMethod?: 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
|
// One ticket per passenger — match by passengerName, not array position (see
|
||||||
// bookings.service.ts's getByRef).
|
// bookings.service.ts's getByRef).
|
||||||
tickets?: Array<{
|
tickets?: Array<{
|
||||||
@@ -37,7 +45,6 @@ export default function ConfirmationPage() {
|
|||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = 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
|
// 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
|
// `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 —
|
// 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).
|
// 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';
|
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 = () => {
|
const copyPNR = () => {
|
||||||
if (pnr) {
|
if (pnr) {
|
||||||
navigator.clipboard.writeText(pnr);
|
navigator.clipboard.writeText(pnr);
|
||||||
@@ -105,15 +101,17 @@ export default function ConfirmationPage() {
|
|||||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
||||||
|
|
||||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||||
// Prefer the amount/currency actually confirmed for the selected payment option;
|
// The server-confirmed settled amount/currency (what was actually charged) is
|
||||||
// only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
|
// authoritative — prefer it over the ETB booking fare once it's available.
|
||||||
const voucherCurrency = 'ETB';
|
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||||
|
const settledCurrency = _booking?.payment?.currency;
|
||||||
|
const voucherCurrency = settledCurrency || 'ETB';
|
||||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||||
const status = _booking?.status || 'CONFIRMED';
|
const status = _booking?.status || 'CONFIRMED';
|
||||||
|
|
||||||
// Compute per-passenger fares using the same logic as the review/payment pages.
|
// Compute per-passenger fares (in ETB) using the same logic as the review/payment
|
||||||
// reviewedPassengerFares is the authoritative source; rebuild from package context
|
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
|
||||||
// as a fallback so free children always show ETB 0.00 on their voucher.
|
// context as a fallback so free children always show 0 on their voucher.
|
||||||
const { packageTierPriceMinor } = useBookingStore.getState();
|
const { packageTierPriceMinor } = useBookingStore.getState();
|
||||||
const isPackageBooking = packageTierPriceMinor != null;
|
const isPackageBooking = packageTierPriceMinor != null;
|
||||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
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 pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
|
||||||
const pkgChildFare = pkgAdultFare;
|
const pkgChildFare = pkgAdultFare;
|
||||||
|
|
||||||
const getVoucherFare = (idx: number): number => {
|
const getEtbFare = (idx: number): number => {
|
||||||
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
|
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
|
||||||
if (isPackageBooking) {
|
if (isPackageBooking) {
|
||||||
const isPkgChild = idx >= adultCount;
|
const isPkgChild = idx >= adultCount;
|
||||||
@@ -133,6 +131,17 @@ export default function ConfirmationPage() {
|
|||||||
return Math.round(totalFare / passengers.length);
|
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 = {
|
const outbound = {
|
||||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
||||||
trainName: 'EDR Express',
|
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="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<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 (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||||
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||||
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||||
|
|||||||
@@ -633,6 +633,20 @@ function BookingDetailContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
||||||
{isConfirmed && (
|
{isConfirmed && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,28 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search } from "lucide-react";
|
import { Search, Phone, Ticket, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
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() {
|
export default function BookingLookupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState<SearchMode>("pnr");
|
||||||
|
|
||||||
|
// PNR mode state
|
||||||
const [bookingRef, setBookingRef] = useState("");
|
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 [error, setError] = useState("");
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
// ── PNR submit ───────────────────────────────────────────────────────────
|
||||||
|
const handlePnrSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = bookingRef.trim().toUpperCase();
|
const trimmed = bookingRef.trim().toUpperCase();
|
||||||
if (!trimmed) {
|
if (!trimmed) { setError("Please enter a booking reference"); return; }
|
||||||
setError("Please enter a booking reference");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/booking/detail?ref=${trimmed}`);
|
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 (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-900">
|
<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="w-full max-w-md">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||||
|
{/* Header */}
|
||||||
<div className="text-center mb-6">
|
<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">
|
<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)]" />
|
<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">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
Find Your Booking
|
Find Your Booking
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
Enter your booking reference (PNR) to view details
|
Search by booking reference or phone number
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
{/* Mode tabs */}
|
||||||
<div className="mb-6">
|
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 mb-6 overflow-hidden">
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
onClick={() => switchMode("pnr")}
|
||||||
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"
|
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" />
|
<Ticket className="w-4 h-4" />
|
||||||
Search Booking
|
Booking Ref (PNR)
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -465,8 +465,10 @@ function validatePhone(phone: string, nationality: string): string | null {
|
|||||||
if (!normalized) return 'Phone number is required';
|
if (!normalized) return 'Phone number is required';
|
||||||
const nat = getPhoneNat(nationality);
|
const nat = getPhoneNat(nationality);
|
||||||
if (nat === 'ETHIOPIAN') {
|
if (nat === 'ETHIOPIAN') {
|
||||||
if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
|
// Only Ethio Telecom (0/+2519...) and Safaricom Ethiopia (0/+2517...) mobile ranges —
|
||||||
return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
|
// 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 (nat === 'DJIBOUTIAN') {
|
||||||
if (/^\+253\d{8}$/.test(normalized)) return null;
|
if (/^\+253\d{8}$/.test(normalized)) return null;
|
||||||
|
|||||||
@@ -313,19 +313,15 @@ export default function ReviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build booking request for authenticated users
|
// Build booking request for authenticated users
|
||||||
// Package bookings only: free children (first child per adult) don't go through
|
// Free children (first child per adult) never go through seat selection and have no
|
||||||
// seat selection and have no seatId, so they're excluded here — the backend derives
|
// seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's
|
||||||
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
|
// seatEligibility: "First adultCount children are free (no seat)... same rule applies"
|
||||||
// (including the free child, who still gets a real seatId and a $0 fare handled by
|
// for regular bookings too). Submitting one anyway sends seatId: undefined, which the
|
||||||
// the backend), so they must stay in the array or that passenger — and their
|
// backend's `seat: { connect: { id } }` rejects — hence excluding them here for both
|
||||||
// ticket/seat/childCount — silently never gets created.
|
// cases. (Their absence from adultCount/childCount on the confirmed booking is a
|
||||||
const bookingPassengers = passengers.filter((_p, i) => {
|
// separate, backend-side gap — not something the frontend can paper over by sending
|
||||||
if (packageId) {
|
// an unseated passenger.)
|
||||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
const bookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i)));
|
||||||
return !isFreePkgChild;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
bookingData = {
|
bookingData = {
|
||||||
passengerId: passengerId,
|
passengerId: passengerId,
|
||||||
@@ -375,19 +371,12 @@ export default function ReviewPage() {
|
|||||||
if (priceTierId) bookingData.priceTierId = priceTierId;
|
if (priceTierId) bookingData.priceTierId = priceTierId;
|
||||||
} else {
|
} else {
|
||||||
// For guests: send full passenger details array
|
// For guests: send full passenger details array
|
||||||
// Package bookings only: free children (first child per adult) don't go through
|
// Free children (first child per adult) never go through seat selection and have no
|
||||||
// seat selection and have no seatId, so they're excluded here — the backend derives
|
// seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's
|
||||||
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
|
// seatEligibility comment). Submitting one anyway sends seatId: undefined, which the
|
||||||
// (including the free child, who still gets a real seatId and a $0 fare handled by
|
// backend's `seat: { connect: { id } }` rejects — hence excluding them here for both
|
||||||
// the backend), so they must stay in the array or that passenger — and their
|
// cases.
|
||||||
// ticket/seat/childCount — silently never gets created.
|
const guestBookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i)));
|
||||||
const guestBookingPassengers = passengers.filter((_p, i) => {
|
|
||||||
if (packageId) {
|
|
||||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
|
||||||
return !isFreePkgChild;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
bookingData = {
|
bookingData = {
|
||||||
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
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
|
// One ticket per passenger, matched below by passengerName — see bookings.service.ts's
|
||||||
// getByRef(). Optional/absent falls back to a client-generated placeholder number.
|
// getByRef(). Optional/absent falls back to a client-generated placeholder number.
|
||||||
tickets?: Array<{ passengerName?: string; barcodePayload?: string }>;
|
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> => {
|
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
|
// 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
|
// 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
|
// 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,
|
status: booking.status,
|
||||||
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
|
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
|
||||||
isRoundTrip: false,
|
isRoundTrip: false,
|
||||||
fareMinor: Math.round(booking.totalMinor / booking.passengers.length),
|
fareMinor: Math.round(totalForSplit / booking.passengers.length),
|
||||||
currency: booking.currency,
|
currency: voucherCurrency,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user