UAT fixes and enhancements

This commit is contained in:
Stephanos A
2026-07-10 10:33:36 +03:00
parent cec544fc12
commit acc93aeffc
16 changed files with 371 additions and 489 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "destinationStationId" TEXT,
ADD COLUMN "originStationId" TEXT;

View File

@@ -537,6 +537,8 @@ model Booking {
returnLeg2OriginStationId String?
returnLeg2DestStationId String?
returnLeg2SeatClassId String?
originStationId String?
destinationStationId String?
outboundBoardedAt DateTime?
returnBoardedAt DateTime?
contactEmail String?

View File

@@ -24,6 +24,14 @@ type IamUserRow = {
verified_by: string | null;
};
function resolvePreferredCurrency(nationality: string | null | undefined, faydaVerified: boolean): string {
if (faydaVerified) return 'ETB';
const n = (nationality ?? '').toLowerCase();
if (n.includes('ethiopi')) return 'ETB';
if (n.includes('djibout')) return 'DJF';
return 'USD';
}
@Injectable()
export class PassengerAuthService {
private readonly logger = new Logger(PassengerAuthService.name);
@@ -250,6 +258,8 @@ export class PassengerAuthService {
if (!passenger) throw new Error('Passenger not found');
const iam = iamRows[0];
const faydaVerified = iam?.verified_by === 'fayda';
const nationality = iam?.metadata?.nationality ?? null;
return {
iamUserId,
@@ -259,7 +269,9 @@ export class PassengerAuthService {
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
faydaVerified: iam?.verified_by === 'fayda',
nationality,
faydaVerified,
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
createdAt: passenger.createdAt,
passenger: {
id: passenger.id,

View File

@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
function generateRef(): string {
@@ -396,7 +397,7 @@ export class BookingsService {
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
package: { select: { id: true, name: true, code: true } },
@@ -453,13 +454,18 @@ export class BookingsService {
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
originStationId: (booking as any).originStationId ?? null,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
originStation: (booking as any).originStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
: booking.schedule.originStation,
destinationStation: (booking as any).destinationStationId
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
@@ -547,7 +553,7 @@ export class BookingsService {
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
@@ -589,6 +595,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
@@ -705,7 +713,7 @@ export class BookingsService {
}
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
@@ -759,6 +767,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
@@ -901,7 +911,7 @@ export class BookingsService {
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -943,6 +953,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
@@ -1095,7 +1107,7 @@ export class BookingsService {
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -1146,6 +1158,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,

View File

@@ -249,6 +249,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
totalMinor: resolvedTotalMinor,
adultCount,
@@ -506,6 +508,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
@@ -707,6 +711,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
@@ -921,6 +927,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.returnLeg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,

View File

@@ -16,7 +16,19 @@ export class CurrenciesService {
orderBy: { toCurrency: 'asc' },
});
return rates.map(rate => ({
const base = {
id: 'etb-base',
code: 'ETB',
name: 'Ethiopian Birr',
symbol: 'Br',
baseCurrencyCode: 'ETB',
exchangeRate: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
return [base, ...rates.map(rate => ({
id: rate.id,
code: rate.toCurrency,
name: this.getCurrencyName(rate.toCurrency),
@@ -26,7 +38,7 @@ export class CurrenciesService {
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
}));
}))];
}
async createCurrency(dto: CreateCurrencyDto) {

View File

@@ -491,7 +491,7 @@ export class SearchService {
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;

View File

@@ -190,7 +190,10 @@ function BookingsPageContent() {
render: (booking: any) => {
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = booking?.returnSchedule?.departureAt;
console.log(JSON.stringify(booking.packageId));
const hasActualStops = booking.schedule?.originStation && booking.schedule?.destinationStation;
const isFullRoute =
!booking.originStationId &&
booking.schedule?.originStation?.id === booking.schedule?.fullOriginStationId;
return (
<div>
<div className="font-medium">

View File

@@ -148,13 +148,6 @@ export default function ClassesPage() {
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
),
},
{
key: 'premiumMinor',
label: 'Premium',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'insuranceFeeMinor',
label: 'Insurance',

View File

@@ -108,6 +108,7 @@ export default function TariffRatesPage() {
nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null,
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
isActive: fd.get('isActive') === 'true',
};
if (editingClass) {
@@ -136,6 +137,9 @@ export default function TariffRatesPage() {
c.bedPosition?.toLowerCase().includes(s) ||
c.coachType?.name?.toLowerCase().includes(s)
);
}).sort((a: any, b: any) => {
if (a.nationalityType === b.nationalityType) return 0;
return a.nationalityType === 'LOCAL' ? -1 : 1;
});
const suggestName = () => {
@@ -171,12 +175,6 @@ export default function TariffRatesPage() {
return <span className="text-sm">{ct ? `${ct.code}${ct.name}` : c.coachTypeId}</span>;
},
},
{
key: 'bedPosition', label: 'Bed Position',
render: (c: any) => c.bedPosition
? <span className="font-mono text-sm">{c.bedPosition}</span>
: <span className="text-muted-foreground text-xs">Standard</span>,
},
{
key: 'name', label: 'Class Name',
render: (c: any) => <span className="font-medium">{c.name}</span>,
@@ -200,6 +198,12 @@ export default function TariffRatesPage() {
);
},
},
{
key: 'insuranceFeeMinor', label: 'Insurance Fee',
render: (c: any) => (
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'isActive', label: 'Status',
render: (c: any) => (
@@ -241,7 +245,7 @@ export default function TariffRatesPage() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, bed position..."
placeholder="Search by name, nationality, etc."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -395,6 +399,20 @@ export default function TariffRatesPage() {
)}
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g. 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
<div>
<label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>

View File

@@ -14,7 +14,7 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const COUNTRIES = [
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
'Djibouti', 'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
@@ -572,12 +572,23 @@ const passengerSchema = z.object({
}
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) {
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
const passportNum = data.passportNumber?.trim() ?? '';
if (!passportNum) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
} else if (/[^A-Za-z0-9]/.test(passportNum)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passportNumber'] });
} else if (passportNum.length < 6 || passportNum.length > 12) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passportNumber'] });
}
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
}
if (data.passportIssueDate) {
const issue = new Date(data.passportIssueDate);
if (!isNaN(issue.getTime()) && issue > new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passportIssueDate'] });
}
}
if (data.passportExpiryDate) {
const expiry = new Date(data.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
@@ -662,7 +673,7 @@ export default function PassengersPage() {
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
nationalId: stored.nationalId || '',
passportNumber: stored.passportNumber || '',
passportCountry: stored.passportCountry || '',
passportCountry: stored.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''),
passportIssueDate: stored.passportIssueDate || '',
passportExpiryDate: stored.passportExpiryDate || '',
passportIssuingAuthority: stored.passportIssuingAuthority || '',
@@ -681,7 +692,7 @@ export default function PassengersPage() {
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
nationalId: '',
passportNumber: '',
passportCountry: '',
passportCountry: searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '',
passportIssueDate: '',
passportExpiryDate: '',
passportIssuingAuthority: '',
@@ -835,7 +846,7 @@ export default function PassengersPage() {
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
@@ -1361,8 +1372,12 @@ export default function PassengersPage() {
<input
type="date"
{...register(`passengers.${index}.passportIssueDate`)}
className="input-field"
className={`input-field ${errors.passengers?.[index]?.passportIssueDate ? 'border-red-500' : ''}`}
max={new Date().toISOString().split('T')[0]}
/>
{errors.passengers?.[index]?.passportIssueDate && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportIssueDate?.message}</p>
)}
</div>
<div>

View File

@@ -22,6 +22,8 @@ import {
} from "lucide-react";
import { format } from "date-fns";
import { formatTime, getTimePeriod } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
import { useCurrencySymbol } from "@/lib/useCurrencies";
import { useState, useEffect } from "react";
export default function ResultsPage() {
@@ -35,6 +37,8 @@ export default function ResultsPage() {
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(
() => useBookingStore.getState().outboundSchedule,
);
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{
code: string;
@@ -84,6 +88,17 @@ export default function ResultsPage() {
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
};
// Initialise effective dates from URL/store once searchData is stable
useEffect(() => {
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchData.date, searchData.returnDate]);
const nat = (searchData.nationality ?? '').toUpperCase();
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
useEffect(() => {
if (searchParams.get("origin")) {
setSearchCriteria({
@@ -249,7 +264,7 @@ export default function ResultsPage() {
const minFare = coachType?.classes.length
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
: 0;
const fareCurrency = "ETB";
const fareCurrency = displayCurrencyCode;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -281,10 +296,21 @@ export default function ResultsPage() {
coachTypes: schedule.coachTypes || [],
};
// Extract the actual date from the schedule (YYYY-MM-DD)
const scheduleDate = schedule.departureAt
? schedule.departureAt.slice(0, 10)
: null;
// For round trip, store outbound and advance to inbound step
if (isRoundTrip && isOutbound) {
setOutboundScheduleData(scheduleData);
setOutboundSchedule(scheduleData);
if (scheduleDate) {
setEffectiveDepartureDate(scheduleDate);
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
}
}
setClassModal(null);
setRoundTripStep("inbound");
window.scrollTo({ top: 0, behavior: "smooth" });
@@ -293,6 +319,12 @@ export default function ResultsPage() {
// For round trip inbound, proceed with both schedules
if (isRoundTrip && !isOutbound) {
if (scheduleDate) {
setEffectiveReturnDate(scheduleDate);
if (searchCriteria && scheduleDate !== searchCriteria.returnDate) {
setSearchCriteria({ ...searchCriteria, returnDate: scheduleDate });
}
}
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
// return seat selection page shows the same prices as the outbound leg.
const inboundScheduleData = outboundScheduleData
@@ -307,6 +339,12 @@ export default function ResultsPage() {
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
} else {
// For one-way
if (scheduleDate) {
setEffectiveDepartureDate(scheduleDate);
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
}
}
setSelectedSchedule(scheduleData);
}
@@ -378,10 +416,10 @@ export default function ResultsPage() {
selectedCoachType?.id === coachType.coachTypeId;
const minPrice = coachType.classes.length
? Math.min(
...coachType.classes.map((c: any) => c.baseFareMinor),
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
)
: 0;
const coachCurrency = "ETB";
const coachCurrency = displayCurrencySymbol;
const CoachIcon = getCoachIcon(coachType.coachTypeName);
const selectThisCoach = () =>
@@ -468,10 +506,7 @@ export default function ResultsPage() {
: "text-gray-900 dark:text-white"
}`}
>
{(minPrice / 100).toFixed(2)}
</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
{coachCurrency}
{formatFare(minPrice, coachCurrency)}
</span>
</div>
</div>
@@ -503,7 +538,7 @@ export default function ResultsPage() {
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{(cls.baseFareMinor / 100).toFixed(2)}
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachCurrency}
@@ -581,11 +616,11 @@ export default function ResultsPage() {
// Calculate lowest fare and display currency from coach types / faresByClass.
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
let lowestFare = null;
const displayCurrency = "ETB";
const displayCurrency = displayCurrencySymbol;
if (schedule.coachTypes?.length) {
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
const allFares = allClasses
.map((c) => c.baseFareMinor)
.map((c) => c.displayAmountMinor ?? c.baseFareMinor)
.filter((f) => f > 0);
lowestFare = allFares.length ? Math.min(...allFares) : null;
} else if (schedule.faresByClass?.length) {
@@ -715,9 +750,7 @@ export default function ResultsPage() {
Starting from
</div>
<div className="text-3xl font-bold text-primary">
{lowestFare
? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}`
: "N/A"}
{lowestFare ? formatFare(lowestFare, displayCurrency) : "N/A"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">
per adult
@@ -1098,8 +1131,8 @@ export default function ResultsPage() {
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
{searchData.date
? format(new Date(searchData.date), "EEEE, MMMM d, yyyy")
{effectiveDepartureDate
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: "Date not specified"}
</span>
</div>
@@ -1129,11 +1162,8 @@ export default function ResultsPage() {
Select Outbound Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.date
? format(
new Date(searchData.date),
"EEEE, MMMM d, yyyy",
)
{effectiveDepartureDate
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: ""}
</p>
</div>
@@ -1182,6 +1212,9 @@ export default function ResultsPage() {
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{outboundScheduleData.origin} {" "}
{outboundScheduleData.destination}
{outboundScheduleData.departureTime
? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}`
: ""}
{outboundScheduleData.selectedSeatClassName
? ` · ${outboundScheduleData.selectedSeatClassName}`
: ""}
@@ -1213,11 +1246,8 @@ export default function ResultsPage() {
Select Return Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.returnDate
? format(
new Date(searchData.returnDate),
"EEEE, MMMM d, yyyy",
)
{effectiveReturnDate
? format(new Date(`${effectiveReturnDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: ""}
</p>
</div>

View File

@@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { useCurrencySymbol } from '@/lib/useCurrencies';
// Helper function to decode JWT token and extract passengerId
function getPassengerIdFromToken(token: string): string | null {
@@ -56,9 +57,10 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
// Prefer the currency already stored on the selected schedule (set from search results).
// Fall back to deriving from nationality so the review page is never left with a stale value.
const displayCurrency = 'ETB';
// Derive display currency from nationality so fares show in the passenger's home currency.
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
useEffect(() => {
if (!seatHold?.expiresAt) return;
@@ -329,7 +331,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency,
displayCurrency: displayCurrencyCode,
passengers: bookingPassengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -386,7 +388,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency,
displayCurrency: displayCurrencyCode,
passengers: guestBookingPassengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -510,7 +512,7 @@ export default function ReviewPage() {
originStationId,
destinationStationId,
passengers: passengersParam,
displayCurrency,
displayCurrency: displayCurrencyCode,
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
});
@@ -518,7 +520,7 @@ export default function ReviewPage() {
setFareBreakdown(result);
} catch (err) {
}
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -589,7 +591,7 @@ export default function ReviewPage() {
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatFare(passengerTotal, displayCurrency)}
{formatFare(passengerTotal, displayCurrencySymbol)}
</span>
</div>
{/* Round-trip: show outbound + inbound breakdown */}
@@ -597,11 +599,11 @@ export default function ReviewPage() {
<div className="mt-1 space-y-0.5 pl-2">
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Outbound</span>
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Return</span>
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
</div>
</div>
)}
@@ -610,7 +612,7 @@ export default function ReviewPage() {
})}
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
</div>
{/* Action buttons — visible only in desktop sidebar */}
@@ -886,49 +888,51 @@ export default function ReviewPage() {
<div className="space-y-3">
{passengers.map((p, i) => (
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
<div className="flex justify-between items-start mb-2">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
<div className="flex items-center justify-between gap-4">
{/* Left — passenger info */}
<div className="min-w-0">
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">{p.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} {p.nationality}
</p>
</div>
{/* Right — seat details */}
{isRoundTrip ? (
<div className="flex gap-2 flex-shrink-0">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).outboundCoachNumber} </span>}
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
</p>
{(p as any).outboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">Return</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).inboundCoachNumber} </span>}
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
</p>
{(p as any).inboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
)}
</div>
</div>
) : (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right flex-shrink-0">
<p className="text-xs text-gray-500 dark:text-gray-400">Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400">{p.coachNumber} </span>}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
</p>
{p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
)}
</div>
)}
</div>
{isRoundTrip ? (
<div className="grid grid-cols-2 gap-3 mt-2">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).outboundCoachNumber} </span>}
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{(p as any).outboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).inboundCoachNumber} </span>}
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{(p as any).inboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
)}
</div>
</div>
) : (
<div className="text-right">
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-medium text-gray-900 dark:text-gray-100">
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} </span>}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
)}
</div>
)}
</div>
))}
</div>
@@ -956,7 +960,7 @@ export default function ReviewPage() {
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
</div>
{createBookingMutation.isError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2">

View File

@@ -17,8 +17,6 @@ import {
Search,
Users,
ChevronDown,
Gift,
Check,
X,
ChevronLeft,
Clock,
@@ -54,7 +52,6 @@ const searchSchema = z
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
errorMap: () => ({ message: "Please select your nationality" }),
}),
promoCode: z.string().optional(),
})
.refine(
(d) => {
@@ -373,8 +370,7 @@ function PassengerModal({
onClick={onClose}
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
>
Done {adultCount + childCount} Passenger
{adultCount + childCount !== 1 ? "s" : ""}
Continue
</button>
</div>
<style>{`@keyframes pax-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
@@ -551,13 +547,6 @@ export default function SearchPage() {
const dark = useDarkMode();
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [promoVisible, setPromoVisible] = useState(false);
const [promoCode, setPromoCode] = useState("");
const [promoValidation, setPromoValidation] = useState<{
valid: boolean;
message: string;
} | null>(null);
const [promoLoading, setPromoLoading] = useState(false);
const [swapping, setSwapping] = useState(false);
const [stationModal, setStationModal] = useState<
"origin" | "destination" | null
@@ -615,7 +604,6 @@ export default function SearchPage() {
// selecting it.
nationality: "" as any,
departureDate: "",
promoCode: "",
},
});
@@ -693,33 +681,6 @@ export default function SearchPage() {
}, 300);
};
const handleValidatePromo = async () => {
if (!promoCode.trim()) return setPromoValidation(null);
setPromoLoading(true);
try {
const res = (await apiClient.post("/promos/validate", {
code: promoCode,
})) as any;
const valid = res.applicable || res.valid;
setPromoValidation({
valid,
message:
res.message || (valid ? "Promo applied!" : "Invalid promo code"),
});
if (valid) setValue("promoCode", promoCode);
else setPromoCode("");
} catch (err: any) {
setPromoValidation({
valid: false,
message:
err?.response?.data?.message || "Promo code is invalid or expired",
});
setPromoCode("");
} finally {
setPromoLoading(false);
}
};
const onSubmit = (data: SearchForm) => {
setHasInteracted(true);
// Clear previous booking selections and search cache before starting a new search
@@ -738,7 +699,6 @@ export default function SearchPage() {
nationality: data.nationality,
...(data.tripType === "ROUND_TRIP" &&
data.returnDate && { returnDate: data.returnDate }),
...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
};
@@ -828,16 +788,7 @@ export default function SearchPage() {
)}
{/* ── 90vh hero with banner image ── */}
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
upward from its bottom-anchored position — give the hero extra height there so the
widget's top edge doesn't creep up into the sticky header. */}
<section
className={`relative ${
tripType === "ROUND_TRIP"
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
: "h-[94vh] min-h-[560px]"
}`}
>
<section className="relative h-[94vh] min-h-[560px]">
{/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden">
<div
@@ -1066,10 +1017,10 @@ export default function SearchPage() {
>
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
<Users className="w-4 h-4 text-primary" />
{totalPassengers} Pax
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Select nationality"}
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
@@ -1204,10 +1155,10 @@ export default function SearchPage() {
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Select nationality"}
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
@@ -1226,340 +1177,129 @@ export default function SearchPage() {
</button>
</div>
) : (
// ROUND TRIP: Two row layout
<div className="space-y-3">
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
<StationDropdown
stations={stations}
value={originId}
excludeId={destId}
placeholder="Departure station"
recentIds={recentStationIds}
onSelect={(s) => {
setHasInteracted(true);
setValue("originStationId", s.id);
if (s.id) saveRecent(s.id);
clearErrors("originStationId");
clearErrors("destinationStationId");
}}
error={
hasInteracted
? errors.originStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
{/* Swap */}
// ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown
stations={stations}
value={originId}
excludeId={destId}
placeholder="Departure"
recentIds={recentStationIds}
onSelect={(s) => {
setHasInteracted(true);
setValue("originStationId", s.id);
if (s.id) saveRecent(s.id);
clearErrors("originStationId");
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.originStationId?.message : undefined}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">{errors.originStationId.message}</p>
)}
</div>
{/* Swap */}
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown
stations={stations}
value={destId}
excludeId={originId}
placeholder="Destination"
recentIds={recentStationIds}
onSelect={(s) => {
setHasInteracted(true);
setValue("destinationStationId", s.id);
if (s.id) saveRecent(s.id);
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.destinationStationId?.message : undefined}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">{errors.destinationStationId.message}</p>
)}
</div>
{/* Departure Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
<ModernDatePicker
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
onChange={(date) => {
setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
trigger("departureDate");
trigger("returnDate");
}}
minDate={new Date()}
placeholder="Select date"
/>
{errors.departureDate && (
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
)}
</div>
{/* Return Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
<ModernDatePicker
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
onChange={(date) => {
setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
trigger("returnDate");
}}
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
placeholder="Select date"
/>
{errors.returnDate && (
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
)}
</div>
{/* Passengers */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
}`}
>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<StationDropdown
stations={stations}
value={destId}
excludeId={originId}
placeholder="Destination station"
recentIds={recentStationIds}
onSelect={(s) => {
setHasInteracted(true);
setValue("destinationStationId", s.id);
if (s.id) saveRecent(s.id);
clearErrors("destinationStationId");
}}
error={
hasInteracted
? errors.destinationStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Departure Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Departure
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
trigger("returnDate");
}}
minDate={new Date()}
placeholder="Select date"
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{/* Return Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Select date"
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
</div>
{/* Row 2: Promo, Passengers, Search */}
<div className="flex items-end gap-2">
{/* Promo Code */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{!promoVisible
? "Promo Code (Optional)"
: "Promo Code"}
</label>
{!promoVisible ? (
<button
type="button"
onClick={() => setPromoVisible(true)}
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left"
>
<Gift className="w-4 h-4 text-primary" />
<span className="text-sm text-gray-400">
Click to add promo code
</span>
</button>
) : (
<div className="space-y-1">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input
type="text"
value={promoCode}
onChange={(e) => {
setPromoCode(
e.target.value.toUpperCase(),
);
if (promoValidation)
setPromoValidation(null);
}}
placeholder="Enter promo code"
onKeyDown={(e) =>
e.key === "Enter" &&
(e.preventDefault(),
handleValidatePromo())
}
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
autoFocus
/>
</div>
<button
type="button"
onClick={handleValidatePromo}
disabled={!promoCode || promoLoading}
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors"
>
{promoLoading ? "..." : "Apply"}
</button>
<button
type="button"
onClick={() => {
setPromoVisible(false);
setPromoCode("");
setPromoValidation(null);
}}
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{promoValidation && (
<div
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}
>
{promoValidation.valid && (
<Check className="w-3.5 h-3.5" />
)}
{promoValidation.message}
</div>
)}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
{/* Pax + Nationality */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Passengers
</label>
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Select nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
</div>
{/* Search Button */}
<div className="flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">
Search
</label>
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
<Search className="w-5 h-5" />
Search
</button>
</div>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
</div>
{/* Search */}
<button
type="submit"
disabled={isLoading}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
<Search className="w-5 h-5" />
Search
</button>
</div>
)}
</div>
{/* Promo - Only visible in ONE WAY mode on desktop */}
{tripType === "ONE_WAY" && (
<div className="mt-3">
{!promoVisible ? (
<button
type="button"
onClick={() => setPromoVisible(true)}
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline"
>
<Gift className="w-3.5 h-3.5" />
Apply Promo Code
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input
type="text"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value.toUpperCase());
if (promoValidation) setPromoValidation(null);
}}
placeholder="Enter promo code"
onKeyDown={(e) =>
e.key === "Enter" &&
(e.preventDefault(), handleValidatePromo())
}
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
autoFocus
/>
</div>
<button
type="button"
onClick={handleValidatePromo}
disabled={!promoCode || promoLoading}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold"
>
{promoLoading ? "..." : "Apply"}
</button>
<button
type="button"
onClick={() => {
setPromoVisible(false);
setPromoCode("");
setPromoValidation(null);
}}
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
>
<X className="w-4 h-4" />
</button>
</div>
{promoValidation && (
<div
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600" : "text-red-500"}`}
>
{promoValidation.valid && (
<Check className="w-3.5 h-3.5" />
)}
{promoValidation.message}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</form>

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from './api-client';
interface Currency {
code: string;
symbol: string;
name: string;
}
const FALLBACK_SYMBOLS: Record<string, string> = {
ETB: 'Br',
DJF: 'Fdj',
USD: '$',
};
export function useCurrencies() {
return useQuery<Currency[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get<Currency[]>('/currencies'),
staleTime: 5 * 60 * 1000,
});
}
export function useCurrencySymbol(code: string): string {
const { data, isLoading, isError } = useCurrencies();
if (isLoading || isError || !data) return FALLBACK_SYMBOLS[code] ?? code;
return data.find(c => c.code === code)?.symbol ?? FALLBACK_SYMBOLS[code] ?? code;
}

View File

@@ -83,8 +83,8 @@ export function getPassengerCategory(passenger: PassengerWithAge): 'ADULT' | 'CH
/**
* Format fare amount for display
*/
export function formatFare(amountMinor: number, currency: string = 'ETB'): string {
return `${currency} ${(amountMinor / 100).toFixed(2)}`;
export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string {
return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`;
}
/**