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? returnLeg2OriginStationId String?
returnLeg2DestStationId String? returnLeg2DestStationId String?
returnLeg2SeatClassId String? returnLeg2SeatClassId String?
originStationId String?
destinationStationId String?
outboundBoardedAt DateTime? outboundBoardedAt DateTime?
returnBoardedAt DateTime? returnBoardedAt DateTime?
contactEmail String? contactEmail String?

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,19 @@ export class CurrenciesService {
orderBy: { toCurrency: 'asc' }, 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, id: rate.id,
code: rate.toCurrency, code: rate.toCurrency,
name: this.getCurrencyName(rate.toCurrency), name: this.getCurrencyName(rate.toCurrency),
@@ -26,7 +38,7 @@ export class CurrenciesService {
isActive: true, isActive: true,
createdAt: rate.createdAt, createdAt: rate.createdAt,
updatedAt: rate.createdAt, updatedAt: rate.createdAt,
})); }))];
} }
async createCurrency(dto: CreateCurrencyDto) { async createCurrency(dto: CreateCurrencyDto) {

View File

@@ -491,7 +491,7 @@ export class SearchService {
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor); const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; 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 const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor; : totalMinor;

View File

@@ -190,7 +190,10 @@ function BookingsPageContent() {
render: (booking: any) => { render: (booking: any) => {
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT'; const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = booking?.returnSchedule?.departureAt; 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 ( return (
<div> <div>
<div className="font-medium"> <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> <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', key: 'insuranceFeeMinor',
label: 'Insurance', label: 'Insurance',

View File

@@ -108,6 +108,7 @@ export default function TariffRatesPage() {
nationalityType: selectedNationalityType, nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null, bedPosition: selectedBedPosition || null,
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0, 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', isActive: fd.get('isActive') === 'true',
}; };
if (editingClass) { if (editingClass) {
@@ -136,6 +137,9 @@ export default function TariffRatesPage() {
c.bedPosition?.toLowerCase().includes(s) || c.bedPosition?.toLowerCase().includes(s) ||
c.coachType?.name?.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 = () => { const suggestName = () => {
@@ -171,12 +175,6 @@ export default function TariffRatesPage() {
return <span className="text-sm">{ct ? `${ct.code}${ct.name}` : c.coachTypeId}</span>; 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', key: 'name', label: 'Class Name',
render: (c: any) => <span className="font-medium">{c.name}</span>, 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', key: 'isActive', label: 'Status',
render: (c: any) => ( 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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input <input
type="text" type="text"
placeholder="Search by name, nationality, bed position..." placeholder="Search by name, nationality, etc."
className="input pl-10 w-full" className="input pl-10 w-full"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@@ -395,6 +399,20 @@ export default function TariffRatesPage() {
)} )}
</div> </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> <div>
<label className="label">Status</label> <label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}> <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 GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const COUNTRIES = [ 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', 'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia', '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', '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'; const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) { 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'] }); 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) { if (!data.passportCountry || data.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] }); 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) { if (data.passportExpiryDate) {
const expiry = new Date(data.passportExpiryDate); const expiry = new Date(data.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) { if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
@@ -662,7 +673,7 @@ export default function PassengersPage() {
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '', email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
nationalId: stored.nationalId || '', nationalId: stored.nationalId || '',
passportNumber: stored.passportNumber || '', passportNumber: stored.passportNumber || '',
passportCountry: stored.passportCountry || '', passportCountry: stored.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''),
passportIssueDate: stored.passportIssueDate || '', passportIssueDate: stored.passportIssueDate || '',
passportExpiryDate: stored.passportExpiryDate || '', passportExpiryDate: stored.passportExpiryDate || '',
passportIssuingAuthority: stored.passportIssuingAuthority || '', passportIssuingAuthority: stored.passportIssuingAuthority || '',
@@ -681,7 +692,7 @@ export default function PassengersPage() {
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '', email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
nationalId: '', nationalId: '',
passportNumber: '', passportNumber: '',
passportCountry: '', passportCountry: searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '',
passportIssueDate: '', passportIssueDate: '',
passportExpiryDate: '', passportExpiryDate: '',
passportIssuingAuthority: '', passportIssuingAuthority: '',
@@ -835,7 +846,7 @@ export default function PassengersPage() {
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); 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?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); 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?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate); if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority); if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
@@ -1361,8 +1372,12 @@ export default function PassengersPage() {
<input <input
type="date" type="date"
{...register(`passengers.${index}.passportIssueDate`)} {...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>
<div> <div>

View File

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

View File

@@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { useCurrencySymbol } from '@/lib/useCurrencies';
// Helper function to decode JWT token and extract passengerId // Helper function to decode JWT token and extract passengerId
function getPassengerIdFromToken(token: string): string | null { function getPassengerIdFromToken(token: string): string | null {
@@ -56,9 +57,10 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
// Prefer the currency already stored on the selected schedule (set from search results). // Derive display currency from nationality so fares show in the passenger's home currency.
// Fall back to deriving from nationality so the review page is never left with a stale value. const nat = (searchCriteria?.nationality ?? '').toUpperCase();
const displayCurrency = 'ETB'; const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
useEffect(() => { useEffect(() => {
if (!seatHold?.expiresAt) return; if (!seatHold?.expiresAt) return;
@@ -329,7 +331,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId, destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency, displayCurrency: displayCurrencyCode,
passengers: bookingPassengers.map((p) => { passengers: bookingPassengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -386,7 +388,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId, destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency, displayCurrency: displayCurrencyCode,
passengers: guestBookingPassengers.map(p => { passengers: guestBookingPassengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -510,7 +512,7 @@ export default function ReviewPage() {
originStationId, originStationId,
destinationStationId, destinationStationId,
passengers: passengersParam, passengers: passengersParam,
displayCurrency, displayCurrency: displayCurrencyCode,
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}), ...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
}); });
@@ -518,7 +520,7 @@ export default function ReviewPage() {
setFareBreakdown(result); setFareBreakdown(result);
} catch (err) { } catch (err) {
} }
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
useEffect(() => { useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -589,7 +591,7 @@ export default function ReviewPage() {
)} )}
</span> </span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100"> <span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatFare(passengerTotal, displayCurrency)} {formatFare(passengerTotal, displayCurrencySymbol)}
</span> </span>
</div> </div>
{/* Round-trip: show outbound + inbound breakdown */} {/* 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="mt-1 space-y-0.5 pl-2">
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400"> <div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Outbound</span> <span> Outbound</span>
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span> <span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
</div> </div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400"> <div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Return</span> <span> Return</span>
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span> <span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
</div> </div>
</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"> <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="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> </div>
{/* Action buttons — visible only in desktop sidebar */} {/* Action buttons — visible only in desktop sidebar */}
@@ -886,49 +888,51 @@ export default function ReviewPage() {
<div className="space-y-3"> <div className="space-y-3">
{passengers.map((p, i) => ( {passengers.map((p, i) => (
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0"> <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 className="flex items-center justify-between gap-4">
<div> {/* Left — passenger info */}
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p> <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 className="text-sm text-gray-600 dark:text-gray-400">
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} {p.nationality} {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} {p.nationality}
</p> </p>
</div> </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> </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>
))} ))}
</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="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"> <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-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> </div>
{createBookingMutation.isError && ( {createBookingMutation.isError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2"> <p className="text-red-600 dark:text-red-400 text-xs mb-2">

View File

@@ -17,8 +17,6 @@ import {
Search, Search,
Users, Users,
ChevronDown, ChevronDown,
Gift,
Check,
X, X,
ChevronLeft, ChevronLeft,
Clock, Clock,
@@ -54,7 +52,6 @@ const searchSchema = z
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], { nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
errorMap: () => ({ message: "Please select your nationality" }), errorMap: () => ({ message: "Please select your nationality" }),
}), }),
promoCode: z.string().optional(),
}) })
.refine( .refine(
(d) => { (d) => {
@@ -373,8 +370,7 @@ function PassengerModal({
onClick={onClose} onClick={onClose}
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl" className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
> >
Done {adultCount + childCount} Passenger Continue
{adultCount + childCount !== 1 ? "s" : ""}
</button> </button>
</div> </div>
<style>{`@keyframes pax-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style> <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 dark = useDarkMode();
const [passengerModalOpen, setPassengerModalOpen] = useState(false); 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 [swapping, setSwapping] = useState(false);
const [stationModal, setStationModal] = useState< const [stationModal, setStationModal] = useState<
"origin" | "destination" | null "origin" | "destination" | null
@@ -615,7 +604,6 @@ export default function SearchPage() {
// selecting it. // selecting it.
nationality: "" as any, nationality: "" as any,
departureDate: "", departureDate: "",
promoCode: "",
}, },
}); });
@@ -693,33 +681,6 @@ export default function SearchPage() {
}, 300); }, 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) => { const onSubmit = (data: SearchForm) => {
setHasInteracted(true); setHasInteracted(true);
// Clear previous booking selections and search cache before starting a new search // Clear previous booking selections and search cache before starting a new search
@@ -738,7 +699,6 @@ export default function SearchPage() {
nationality: data.nationality, nationality: data.nationality,
...(data.tripType === "ROUND_TRIP" && ...(data.tripType === "ROUND_TRIP" &&
data.returnDate && { returnDate: data.returnDate }), data.returnDate && { returnDate: data.returnDate }),
...(data.promoCode && { promoCode: data.promoCode }),
}); });
router.push(`/booking/results?${params}`); router.push(`/booking/results?${params}`);
}; };
@@ -828,16 +788,7 @@ export default function SearchPage() {
)} )}
{/* ── 90vh hero with banner image ── */} {/* ── 90vh hero with banner image ── */}
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows <section className="relative h-[94vh] min-h-[560px]">
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]"
}`}
>
{/* Background image with zoom - fully isolated */} {/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div <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' }}> <span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
<Users className="w-4 h-4 text-primary" /> <Users className="w-4 h-4 text-primary" />
{totalPassengers} Pax {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality")) {nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}` ? ` · ${nationalityFlag(watch("nationality"))}`
: " · Select nationality"} : " · Nationality"}
</span> </span>
<ChevronDown className="w-4 h-4 text-primary" /> <ChevronDown className="w-4 h-4 text-primary" />
</button> </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"> <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" /> <Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality")) {nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}` ? ` · ${nationalityFlag(watch("nationality"))}`
: " · Select nationality"} : " · Nationality"}
</span> </span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" /> <ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button> </button>
@@ -1226,340 +1177,129 @@ export default function SearchPage() {
</button> </button>
</div> </div>
) : ( ) : (
// ROUND TRIP: Two row layout // ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search
<div className="space-y-3"> <div className="flex items-end gap-2">
{/* Row 1: From, Swap, To, Departure Date, Return Date */} {/* From */}
<div className="flex items-end gap-2"> <div className="flex-1 min-w-0 space-y-1">
{/* From */} <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<div className="flex-1 min-w-0 space-y-1"> <StationDropdown
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> stations={stations}
From value={originId}
</label> excludeId={destId}
<StationDropdown placeholder="Departure"
stations={stations} recentIds={recentStationIds}
value={originId} onSelect={(s) => {
excludeId={destId} setHasInteracted(true);
placeholder="Departure station" setValue("originStationId", s.id);
recentIds={recentStationIds} if (s.id) saveRecent(s.id);
onSelect={(s) => { clearErrors("originStationId");
setHasInteracted(true); clearErrors("destinationStationId");
setValue("originStationId", s.id); }}
if (s.id) saveRecent(s.id); error={hasInteracted ? errors.originStationId?.message : undefined}
clearErrors("originStationId"); onOpen={scrollWidgetIntoView}
clearErrors("destinationStationId"); />
}} {hasInteracted && errors.originStationId && (
error={ <p className="text-xs text-red-500">{errors.originStationId.message}</p>
hasInteracted )}
? errors.originStationId?.message </div>
: undefined {/* Swap */}
} <button
onOpen={scrollWidgetIntoView} type="button"
/> onClick={handleSwap}
{hasInteracted && errors.originStationId && ( disabled={!originId || !destId}
<p className="text-xs text-red-500"> 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" : ""}`}
{errors.originStationId.message} >
</p> <ArrowLeftRight className="w-4 h-4 text-gray-500" />
)} </button>
</div> {/* To */}
{/* Swap */} <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 <button
type="button" type="button"
onClick={handleSwap} onClick={() => setPassengerModalOpen(true)}
disabled={!originId || !destId} 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 ${
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" : ""}`} 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> </button>
{/* To */} {showNationalityError && (
<div className="flex-1 min-w-0 space-y-1"> <p className="text-xs text-red-500">{errors.nationality?.message}</p>
<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>
</div> </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>
)} )}
</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>
</div> </div>
</form> </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 * Format fare amount for display
*/ */
export function formatFare(amountMinor: number, currency: string = 'ETB'): string { export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string {
return `${currency} ${(amountMinor / 100).toFixed(2)}`; return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`;
} }
/** /**