mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
UAT fixes and enhancements
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "destinationStationId" TEXT,
|
||||||
|
ADD COLUMN "originStationId" TEXT;
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
28
apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
Normal file
28
apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user