mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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?
|
||||
returnLeg2DestStationId String?
|
||||
returnLeg2SeatClassId String?
|
||||
originStationId String?
|
||||
destinationStationId String?
|
||||
outboundBoardedAt DateTime?
|
||||
returnBoardedAt DateTime?
|
||||
contactEmail String?
|
||||
|
||||
@@ -24,6 +24,14 @@ type IamUserRow = {
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
function resolvePreferredCurrency(nationality: string | null | undefined, faydaVerified: boolean): string {
|
||||
if (faydaVerified) return 'ETB';
|
||||
const n = (nationality ?? '').toLowerCase();
|
||||
if (n.includes('ethiopi')) return 'ETB';
|
||||
if (n.includes('djibout')) return 'DJF';
|
||||
return 'USD';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PassengerAuthService {
|
||||
private readonly logger = new Logger(PassengerAuthService.name);
|
||||
@@ -250,6 +258,8 @@ export class PassengerAuthService {
|
||||
|
||||
if (!passenger) throw new Error('Passenger not found');
|
||||
const iam = iamRows[0];
|
||||
const faydaVerified = iam?.verified_by === 'fayda';
|
||||
const nationality = iam?.metadata?.nationality ?? null;
|
||||
|
||||
return {
|
||||
iamUserId,
|
||||
@@ -259,7 +269,9 @@ export class PassengerAuthService {
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
faydaVerified: iam?.verified_by === 'fayda',
|
||||
nationality,
|
||||
faydaVerified,
|
||||
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
|
||||
createdAt: passenger.createdAt,
|
||||
passenger: {
|
||||
id: passenger.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
function generateRef(): string {
|
||||
@@ -396,7 +397,7 @@ export class BookingsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
package: { select: { id: true, name: true, code: true } },
|
||||
@@ -453,13 +454,18 @@ export class BookingsService {
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
originStationId: (booking as any).originStationId ?? null,
|
||||
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
passengers: uniquePassengers,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
originStation: (booking as any).originStationId
|
||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
|
||||
: booking.schedule.originStation,
|
||||
destinationStation: (booking as any).destinationStationId
|
||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
|
||||
: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
@@ -547,7 +553,7 @@ export class BookingsService {
|
||||
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
||||
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
|
||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||
@@ -589,6 +595,8 @@ export class BookingsService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor / 100,
|
||||
@@ -705,7 +713,7 @@ export class BookingsService {
|
||||
}
|
||||
const taxesMinor = 0;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
let displayTotalMinor = totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
@@ -759,6 +767,8 @@ export class BookingsService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
@@ -901,7 +911,7 @@ export class BookingsService {
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
@@ -943,6 +953,8 @@ export class BookingsService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.transitStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
@@ -1095,7 +1107,7 @@ export class BookingsService {
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
@@ -1146,6 +1158,8 @@ export class BookingsService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.leg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
|
||||
@@ -249,6 +249,8 @@ export class GuestBookingService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
adultCount,
|
||||
@@ -506,6 +508,8 @@ export class GuestBookingService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
@@ -707,6 +711,8 @@ export class GuestBookingService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.leg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
@@ -921,6 +927,8 @@ export class GuestBookingService {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
|
||||
@@ -16,7 +16,19 @@ export class CurrenciesService {
|
||||
orderBy: { toCurrency: 'asc' },
|
||||
});
|
||||
|
||||
return rates.map(rate => ({
|
||||
const base = {
|
||||
id: 'etb-base',
|
||||
code: 'ETB',
|
||||
name: 'Ethiopian Birr',
|
||||
symbol: 'Br',
|
||||
baseCurrencyCode: 'ETB',
|
||||
exchangeRate: 1,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
return [base, ...rates.map(rate => ({
|
||||
id: rate.id,
|
||||
code: rate.toCurrency,
|
||||
name: this.getCurrencyName(rate.toCurrency),
|
||||
@@ -26,7 +38,7 @@ export class CurrenciesService {
|
||||
isActive: true,
|
||||
createdAt: rate.createdAt,
|
||||
updatedAt: rate.createdAt,
|
||||
}));
|
||||
}))];
|
||||
}
|
||||
|
||||
async createCurrency(dto: CreateCurrencyDto) {
|
||||
|
||||
@@ -491,7 +491,7 @@ export class SearchService {
|
||||
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
@@ -190,7 +190,10 @@ function BookingsPageContent() {
|
||||
render: (booking: any) => {
|
||||
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const returnDeparture = booking?.returnSchedule?.departureAt;
|
||||
console.log(JSON.stringify(booking.packageId));
|
||||
const hasActualStops = booking.schedule?.originStation && booking.schedule?.destinationStation;
|
||||
const isFullRoute =
|
||||
!booking.originStationId &&
|
||||
booking.schedule?.originStation?.id === booking.schedule?.fullOriginStationId;
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
|
||||
@@ -148,13 +148,6 @@ export default function ClassesPage() {
|
||||
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'premiumMinor',
|
||||
label: 'Premium',
|
||||
render: (cls: any) => (
|
||||
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'insuranceFeeMinor',
|
||||
label: 'Insurance',
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function TariffRatesPage() {
|
||||
nationalityType: selectedNationalityType,
|
||||
bedPosition: selectedBedPosition || null,
|
||||
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
||||
insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
|
||||
isActive: fd.get('isActive') === 'true',
|
||||
};
|
||||
if (editingClass) {
|
||||
@@ -136,6 +137,9 @@ export default function TariffRatesPage() {
|
||||
c.bedPosition?.toLowerCase().includes(s) ||
|
||||
c.coachType?.name?.toLowerCase().includes(s)
|
||||
);
|
||||
}).sort((a: any, b: any) => {
|
||||
if (a.nationalityType === b.nationalityType) return 0;
|
||||
return a.nationalityType === 'LOCAL' ? -1 : 1;
|
||||
});
|
||||
|
||||
const suggestName = () => {
|
||||
@@ -171,12 +175,6 @@ export default function TariffRatesPage() {
|
||||
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'bedPosition', label: 'Bed Position',
|
||||
render: (c: any) => c.bedPosition
|
||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
||||
: <span className="text-muted-foreground text-xs">Standard</span>,
|
||||
},
|
||||
{
|
||||
key: 'name', label: 'Class Name',
|
||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||
@@ -200,6 +198,12 @@ export default function TariffRatesPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'insuranceFeeMinor', label: 'Insurance Fee',
|
||||
render: (c: any) => (
|
||||
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: 'Status',
|
||||
render: (c: any) => (
|
||||
@@ -241,7 +245,7 @@ export default function TariffRatesPage() {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, nationality, bed position..."
|
||||
placeholder="Search by name, nationality, etc."
|
||||
className="input pl-10 w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -395,6 +399,20 @@ export default function TariffRatesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Insurance Fee (ETB)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="insuranceFeeMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 25.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
|
||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
const COUNTRIES = [
|
||||
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
|
||||
'Djibouti', 'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
|
||||
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
|
||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||
@@ -572,12 +572,23 @@ const passengerSchema = z.object({
|
||||
}
|
||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||
if (isNonEthiopian) {
|
||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||
const passportNum = data.passportNumber?.trim() ?? '';
|
||||
if (!passportNum) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
||||
} else if (/[^A-Za-z0-9]/.test(passportNum)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passportNumber'] });
|
||||
} else if (passportNum.length < 6 || passportNum.length > 12) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passportNumber'] });
|
||||
}
|
||||
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||
}
|
||||
if (data.passportIssueDate) {
|
||||
const issue = new Date(data.passportIssueDate);
|
||||
if (!isNaN(issue.getTime()) && issue > new Date()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passportIssueDate'] });
|
||||
}
|
||||
}
|
||||
if (data.passportExpiryDate) {
|
||||
const expiry = new Date(data.passportExpiryDate);
|
||||
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
|
||||
@@ -662,7 +673,7 @@ export default function PassengersPage() {
|
||||
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
|
||||
nationalId: stored.nationalId || '',
|
||||
passportNumber: stored.passportNumber || '',
|
||||
passportCountry: stored.passportCountry || '',
|
||||
passportCountry: stored.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''),
|
||||
passportIssueDate: stored.passportIssueDate || '',
|
||||
passportExpiryDate: stored.passportExpiryDate || '',
|
||||
passportIssuingAuthority: stored.passportIssuingAuthority || '',
|
||||
@@ -681,7 +692,7 @@ export default function PassengersPage() {
|
||||
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
|
||||
nationalId: '',
|
||||
passportNumber: '',
|
||||
passportCountry: '',
|
||||
passportCountry: searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '',
|
||||
passportIssueDate: '',
|
||||
passportExpiryDate: '',
|
||||
passportIssuingAuthority: '',
|
||||
@@ -835,7 +846,7 @@ export default function PassengersPage() {
|
||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
|
||||
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
||||
@@ -1361,8 +1372,12 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.passportIssueDate ? 'border-red-500' : ''}`}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportIssueDate && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportIssueDate?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod } from "@/utils/format";
|
||||
import { formatFare } from "@/utils/fare-utils";
|
||||
import { useCurrencySymbol } from "@/lib/useCurrencies";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function ResultsPage() {
|
||||
@@ -35,6 +37,8 @@ export default function ResultsPage() {
|
||||
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(
|
||||
() => useBookingStore.getState().outboundSchedule,
|
||||
);
|
||||
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
|
||||
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{
|
||||
code: string;
|
||||
@@ -84,6 +88,17 @@ export default function ResultsPage() {
|
||||
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
|
||||
};
|
||||
|
||||
// Initialise effective dates from URL/store once searchData is stable
|
||||
useEffect(() => {
|
||||
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
|
||||
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchData.date, searchData.returnDate]);
|
||||
|
||||
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("origin")) {
|
||||
setSearchCriteria({
|
||||
@@ -249,7 +264,7 @@ export default function ResultsPage() {
|
||||
const minFare = coachType?.classes.length
|
||||
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
|
||||
: 0;
|
||||
const fareCurrency = "ETB";
|
||||
const fareCurrency = displayCurrencyCode;
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
@@ -281,10 +296,21 @@ export default function ResultsPage() {
|
||||
coachTypes: schedule.coachTypes || [],
|
||||
};
|
||||
|
||||
// Extract the actual date from the schedule (YYYY-MM-DD)
|
||||
const scheduleDate = schedule.departureAt
|
||||
? schedule.departureAt.slice(0, 10)
|
||||
: null;
|
||||
|
||||
// For round trip, store outbound and advance to inbound step
|
||||
if (isRoundTrip && isOutbound) {
|
||||
setOutboundScheduleData(scheduleData);
|
||||
setOutboundSchedule(scheduleData);
|
||||
if (scheduleDate) {
|
||||
setEffectiveDepartureDate(scheduleDate);
|
||||
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
|
||||
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
|
||||
}
|
||||
}
|
||||
setClassModal(null);
|
||||
setRoundTripStep("inbound");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -293,6 +319,12 @@ export default function ResultsPage() {
|
||||
|
||||
// For round trip inbound, proceed with both schedules
|
||||
if (isRoundTrip && !isOutbound) {
|
||||
if (scheduleDate) {
|
||||
setEffectiveReturnDate(scheduleDate);
|
||||
if (searchCriteria && scheduleDate !== searchCriteria.returnDate) {
|
||||
setSearchCriteria({ ...searchCriteria, returnDate: scheduleDate });
|
||||
}
|
||||
}
|
||||
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
|
||||
// return seat selection page shows the same prices as the outbound leg.
|
||||
const inboundScheduleData = outboundScheduleData
|
||||
@@ -307,6 +339,12 @@ export default function ResultsPage() {
|
||||
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||
} else {
|
||||
// For one-way
|
||||
if (scheduleDate) {
|
||||
setEffectiveDepartureDate(scheduleDate);
|
||||
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
|
||||
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
|
||||
}
|
||||
}
|
||||
setSelectedSchedule(scheduleData);
|
||||
}
|
||||
|
||||
@@ -378,10 +416,10 @@ export default function ResultsPage() {
|
||||
selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length
|
||||
? Math.min(
|
||||
...coachType.classes.map((c: any) => c.baseFareMinor),
|
||||
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
|
||||
)
|
||||
: 0;
|
||||
const coachCurrency = "ETB";
|
||||
const coachCurrency = displayCurrencySymbol;
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
const selectThisCoach = () =>
|
||||
@@ -468,10 +506,7 @@ export default function ResultsPage() {
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{(minPrice / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
{coachCurrency}
|
||||
{formatFare(minPrice, coachCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,7 +538,7 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
||||
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{coachCurrency}
|
||||
@@ -581,11 +616,11 @@ export default function ResultsPage() {
|
||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||
let lowestFare = null;
|
||||
const displayCurrency = "ETB";
|
||||
const displayCurrency = displayCurrencySymbol;
|
||||
if (schedule.coachTypes?.length) {
|
||||
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
||||
const allFares = allClasses
|
||||
.map((c) => c.baseFareMinor)
|
||||
.map((c) => c.displayAmountMinor ?? c.baseFareMinor)
|
||||
.filter((f) => f > 0);
|
||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||
} else if (schedule.faresByClass?.length) {
|
||||
@@ -715,9 +750,7 @@ export default function ResultsPage() {
|
||||
Starting from
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{lowestFare
|
||||
? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}`
|
||||
: "N/A"}
|
||||
{lowestFare ? formatFare(lowestFare, displayCurrency) : "N/A"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">
|
||||
per adult
|
||||
@@ -1098,8 +1131,8 @@ export default function ResultsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{searchData.date
|
||||
? format(new Date(searchData.date), "EEEE, MMMM d, yyyy")
|
||||
{effectiveDepartureDate
|
||||
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||
: "Date not specified"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1129,11 +1162,8 @@ export default function ResultsPage() {
|
||||
Select Outbound Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.date
|
||||
? format(
|
||||
new Date(searchData.date),
|
||||
"EEEE, MMMM d, yyyy",
|
||||
)
|
||||
{effectiveDepartureDate
|
||||
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1182,6 +1212,9 @@ export default function ResultsPage() {
|
||||
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{outboundScheduleData.origin} →{" "}
|
||||
{outboundScheduleData.destination}
|
||||
{outboundScheduleData.departureTime
|
||||
? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}`
|
||||
: ""}
|
||||
{outboundScheduleData.selectedSeatClassName
|
||||
? ` · ${outboundScheduleData.selectedSeatClassName}`
|
||||
: ""}
|
||||
@@ -1213,11 +1246,8 @@ export default function ResultsPage() {
|
||||
Select Return Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.returnDate
|
||||
? format(
|
||||
new Date(searchData.returnDate),
|
||||
"EEEE, MMMM d, yyyy",
|
||||
)
|
||||
{effectiveReturnDate
|
||||
? format(new Date(`${effectiveReturnDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
import { useCurrencySymbol } from '@/lib/useCurrencies';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
@@ -56,9 +57,10 @@ export default function ReviewPage() {
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
// Prefer the currency already stored on the selected schedule (set from search results).
|
||||
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
||||
const displayCurrency = 'ETB';
|
||||
// Derive display currency from nationality so fares show in the passenger's home currency.
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
@@ -329,7 +331,7 @@ export default function ReviewPage() {
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: displayCurrency,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
passengers: bookingPassengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
||||
@@ -386,7 +388,7 @@ export default function ReviewPage() {
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: displayCurrency,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
passengers: guestBookingPassengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
||||
@@ -510,7 +512,7 @@ export default function ReviewPage() {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
passengers: passengersParam,
|
||||
displayCurrency,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||
});
|
||||
|
||||
@@ -518,7 +520,7 @@ export default function ReviewPage() {
|
||||
setFareBreakdown(result);
|
||||
} catch (err) {
|
||||
}
|
||||
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
|
||||
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
||||
@@ -589,7 +591,7 @@ export default function ReviewPage() {
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatFare(passengerTotal, displayCurrency)}
|
||||
{formatFare(passengerTotal, displayCurrencySymbol)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Round-trip: show outbound + inbound breakdown */}
|
||||
@@ -597,11 +599,11 @@ export default function ReviewPage() {
|
||||
<div className="mt-1 space-y-0.5 pl-2">
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↗ Outbound</span>
|
||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
|
||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↙ Return</span>
|
||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
|
||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -610,7 +612,7 @@ export default function ReviewPage() {
|
||||
})}
|
||||
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-xl font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
||||
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons — visible only in desktop sidebar */}
|
||||
@@ -886,49 +888,51 @@ export default function ReviewPage() {
|
||||
<div className="space-y-3">
|
||||
{passengers.map((p, i) => (
|
||||
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left — passenger info */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
{/* Right — seat details */}
|
||||
{isRoundTrip ? (
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).outboundCoachNumber} — </span>}
|
||||
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||
</p>
|
||||
{(p as any).outboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Return</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).inboundCoachNumber} — </span>}
|
||||
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||
</p>
|
||||
{(p as any).inboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right flex-shrink-0">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400">{p.coachNumber} — </span>}
|
||||
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||
</p>
|
||||
{p.seatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isRoundTrip ? (
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).outboundCoachNumber} — </span>}
|
||||
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
||||
</p>
|
||||
{(p as any).outboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).inboundCoachNumber} —</span>}
|
||||
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
||||
</p>
|
||||
{(p as any).inboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} — </span>}
|
||||
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
||||
</p>
|
||||
{p.seatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -956,7 +960,7 @@ export default function ReviewPage() {
|
||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
||||
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||
</div>
|
||||
{createBookingMutation.isError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
Search,
|
||||
Users,
|
||||
ChevronDown,
|
||||
Gift,
|
||||
Check,
|
||||
X,
|
||||
ChevronLeft,
|
||||
Clock,
|
||||
@@ -54,7 +52,6 @@ const searchSchema = z
|
||||
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
|
||||
errorMap: () => ({ message: "Please select your nationality" }),
|
||||
}),
|
||||
promoCode: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) => {
|
||||
@@ -373,8 +370,7 @@ function PassengerModal({
|
||||
onClick={onClose}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
|
||||
>
|
||||
Done — {adultCount + childCount} Passenger
|
||||
{adultCount + childCount !== 1 ? "s" : ""}
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
<style>{`@keyframes pax-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
|
||||
@@ -551,13 +547,6 @@ export default function SearchPage() {
|
||||
|
||||
const dark = useDarkMode();
|
||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||
const [promoVisible, setPromoVisible] = useState(false);
|
||||
const [promoCode, setPromoCode] = useState("");
|
||||
const [promoValidation, setPromoValidation] = useState<{
|
||||
valid: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [promoLoading, setPromoLoading] = useState(false);
|
||||
const [swapping, setSwapping] = useState(false);
|
||||
const [stationModal, setStationModal] = useState<
|
||||
"origin" | "destination" | null
|
||||
@@ -615,7 +604,6 @@ export default function SearchPage() {
|
||||
// selecting it.
|
||||
nationality: "" as any,
|
||||
departureDate: "",
|
||||
promoCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -693,33 +681,6 @@ export default function SearchPage() {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleValidatePromo = async () => {
|
||||
if (!promoCode.trim()) return setPromoValidation(null);
|
||||
setPromoLoading(true);
|
||||
try {
|
||||
const res = (await apiClient.post("/promos/validate", {
|
||||
code: promoCode,
|
||||
})) as any;
|
||||
const valid = res.applicable || res.valid;
|
||||
setPromoValidation({
|
||||
valid,
|
||||
message:
|
||||
res.message || (valid ? "Promo applied!" : "Invalid promo code"),
|
||||
});
|
||||
if (valid) setValue("promoCode", promoCode);
|
||||
else setPromoCode("");
|
||||
} catch (err: any) {
|
||||
setPromoValidation({
|
||||
valid: false,
|
||||
message:
|
||||
err?.response?.data?.message || "Promo code is invalid or expired",
|
||||
});
|
||||
setPromoCode("");
|
||||
} finally {
|
||||
setPromoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setHasInteracted(true);
|
||||
// Clear previous booking selections and search cache before starting a new search
|
||||
@@ -738,7 +699,6 @@ export default function SearchPage() {
|
||||
nationality: data.nationality,
|
||||
...(data.tripType === "ROUND_TRIP" &&
|
||||
data.returnDate && { returnDate: data.returnDate }),
|
||||
...(data.promoCode && { promoCode: data.promoCode }),
|
||||
});
|
||||
router.push(`/booking/results?${params}`);
|
||||
};
|
||||
@@ -828,16 +788,7 @@ export default function SearchPage() {
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
|
||||
upward from its bottom-anchored position — give the hero extra height there so the
|
||||
widget's top edge doesn't creep up into the sticky header. */}
|
||||
<section
|
||||
className={`relative ${
|
||||
tripType === "ROUND_TRIP"
|
||||
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
|
||||
: "h-[94vh] min-h-[560px]"
|
||||
}`}
|
||||
>
|
||||
<section className="relative h-[94vh] min-h-[560px]">
|
||||
{/* Background image with zoom - fully isolated */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
@@ -1066,10 +1017,10 @@ export default function SearchPage() {
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
{totalPassengers} Pax
|
||||
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||
{nationalityFlag(watch("nationality"))
|
||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||
: " · Select nationality"}
|
||||
: " · Nationality"}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary" />
|
||||
</button>
|
||||
@@ -1204,10 +1155,10 @@ export default function SearchPage() {
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax
|
||||
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||
{nationalityFlag(watch("nationality"))
|
||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||
: " · Select nationality"}
|
||||
: " · Nationality"}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
@@ -1226,340 +1177,129 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// ROUND TRIP: Two row layout
|
||||
<div className="space-y-3">
|
||||
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
From
|
||||
</label>
|
||||
<StationDropdown
|
||||
stations={stations}
|
||||
value={originId}
|
||||
excludeId={destId}
|
||||
placeholder="Departure station"
|
||||
recentIds={recentStationIds}
|
||||
onSelect={(s) => {
|
||||
setHasInteracted(true);
|
||||
setValue("originStationId", s.id);
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("originStationId");
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.originStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.originStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
// ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown
|
||||
stations={stations}
|
||||
value={originId}
|
||||
excludeId={destId}
|
||||
placeholder="Departure"
|
||||
recentIds={recentStationIds}
|
||||
onSelect={(s) => {
|
||||
setHasInteracted(true);
|
||||
setValue("originStationId", s.id);
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("originStationId");
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.originStationId?.message : undefined}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
<p className="text-xs text-red-500">{errors.originStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown
|
||||
stations={stations}
|
||||
value={destId}
|
||||
excludeId={originId}
|
||||
placeholder="Destination"
|
||||
recentIds={recentStationIds}
|
||||
onSelect={(s) => {
|
||||
setHasInteracted(true);
|
||||
setValue("destinationStationId", s.id);
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.destinationStationId?.message : undefined}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
<p className="text-xs text-red-500">{errors.destinationStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Departure Date */}
|
||||
<div className="w-40 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
|
||||
onChange={(date) => {
|
||||
setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||
trigger("departureDate");
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Return Date */}
|
||||
<div className="w-40 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
|
||||
<ModernDatePicker
|
||||
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
|
||||
onChange={(date) => {
|
||||
setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
{errors.returnDate && (
|
||||
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Passengers */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
|
||||
onClick={() => setPassengerModalOpen(true)}
|
||||
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
|
||||
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
To
|
||||
</label>
|
||||
<StationDropdown
|
||||
stations={stations}
|
||||
value={destId}
|
||||
excludeId={originId}
|
||||
placeholder="Destination station"
|
||||
recentIds={recentStationIds}
|
||||
onSelect={(s) => {
|
||||
setHasInteracted(true);
|
||||
setValue("destinationStationId", s.id);
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.destinationStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.destinationStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Departure Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Departure
|
||||
</label>
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: undefined
|
||||
}
|
||||
onChange={(date) => {
|
||||
setValue(
|
||||
"departureDate",
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
trigger("departureDate");
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.departureDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Return Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Return
|
||||
</label>
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
returnDate
|
||||
? new Date(returnDate + "T00:00:00")
|
||||
: undefined
|
||||
}
|
||||
onChange={(date) => {
|
||||
setValue(
|
||||
"returnDate",
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.returnDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Promo, Passengers, Search */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Promo Code */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
{!promoVisible
|
||||
? "Promo Code (Optional)"
|
||||
: "Promo Code"}
|
||||
</label>
|
||||
{!promoVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromoVisible(true)}
|
||||
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left"
|
||||
>
|
||||
<Gift className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm text-gray-400">
|
||||
Click to add promo code
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(
|
||||
e.target.value.toUpperCase(),
|
||||
);
|
||||
if (promoValidation)
|
||||
setPromoValidation(null);
|
||||
}}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(e.preventDefault(),
|
||||
handleValidatePromo())
|
||||
}
|
||||
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidatePromo}
|
||||
disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{promoLoading ? "..." : "Apply"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPromoVisible(false);
|
||||
setPromoCode("");
|
||||
setPromoValidation(null);
|
||||
}}
|
||||
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}
|
||||
>
|
||||
{promoValidation.valid && (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
{/* Pax + Nationality */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Passengers
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPassengerModalOpen(true)}
|
||||
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
|
||||
showNationalityError
|
||||
? "border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax
|
||||
{nationalityFlag(watch("nationality"))
|
||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||
: " · Select nationality"}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
{showNationalityError && (
|
||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Search Button */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">
|
||||
Search
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
{showNationalityError && (
|
||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Search */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Promo - Only visible in ONE WAY mode on desktop */}
|
||||
{tripType === "ONE_WAY" && (
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline"
|
||||
>
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
if (promoValidation) setPromoValidation(null);
|
||||
}}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(e.preventDefault(), handleValidatePromo())
|
||||
}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidatePromo}
|
||||
disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold"
|
||||
>
|
||||
{promoLoading ? "..." : "Apply"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPromoVisible(false);
|
||||
setPromoCode("");
|
||||
setPromoValidation(null);
|
||||
}}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600" : "text-red-500"}`}
|
||||
>
|
||||
{promoValidation.valid && (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
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
|
||||
*/
|
||||
export function formatFare(amountMinor: number, currency: string = 'ETB'): string {
|
||||
return `${currency} ${(amountMinor / 100).toFixed(2)}`;
|
||||
export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string {
|
||||
return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user