Merge pull request #520 from Tria-plc/alpha

Set nationality required on booking widget
This commit is contained in:
Stephanos A.
2026-07-08 00:46:38 +03:00
committed by GitHub
5 changed files with 827 additions and 429 deletions

View File

@@ -30,13 +30,10 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
const nationalityUpper = (dto.nationality ?? '').toUpperCase(); const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL'; ? 'LOCAL' : 'INTERNATIONAL';
// Find the nationality-specific seat class for the same coach type and bed position.
// Falls back to the requested seatClass if no nationality-specific one exists.
const nationalitySeatClass = await this.prisma.seatClass.findFirst({ const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: { where: {
coachTypeId: seatClass.coachTypeId, coachTypeId: seatClass.coachTypeId,
@@ -46,13 +43,10 @@ export class FareEngineService {
}, },
}) ?? seatClass; }) ?? seatClass;
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
throw new BadRequestException('Invalid distance calculation - check route stop distances'); throw new BadRequestException('Invalid distance calculation - check route stop distances');
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
const now = new Date(); const now = new Date();
const [originStation, destStation] = await Promise.all([ const [originStation, destStation] = await Promise.all([
this.prisma.station.findUnique({ where: { id: dto.originStationId } }), this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
@@ -81,8 +75,12 @@ export class FareEngineService {
let baseFarePerPassengerMinor: number; let baseFarePerPassengerMinor: number;
let ratePerKmMinor: number; let ratePerKmMinor: number;
let fareSource: string; let fareSource: string;
let insuranceFactor = 1;
let usdToEtbRate = 1;
// When insuranceFeeMinor is used as a multiplier in the formula it must not
// be added again as a flat fee. This flag tracks that.
let insuranceAlreadyInBase = false;
// 1. Segment override: exact origin→destination stop pair on this route
const segmentOverride = await this.prisma.segmentFareRule.findFirst({ const segmentOverride = await this.prisma.segmentFareRule.findFirst({
where: { where: {
routeId: route.id, routeId: route.id,
@@ -106,39 +104,46 @@ export class FareEngineService {
}); });
if (segmentOverride) { if (segmentOverride) {
// Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
baseFarePerPassengerMinor = segmentOverride.baseFareMinor; baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE'; fareSource = 'SEGMENT_FARE_RULE';
} else if (fareRule?.tripId) { } else if (fareRule?.tripId) {
// Schedule-scoped flat override
baseFarePerPassengerMinor = fareRule.baseFareMinor; baseFarePerPassengerMinor = fareRule.baseFareMinor;
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE'; fareSource = 'SCHEDULE_FARE_RULE';
} else { } else {
// Default: distance-based using tariff formula: km × rate × 1.02 // Distance-based formula:
// baseFareMinor stores the per-km rate (tariff decimal × 100000) // baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate
// baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km.
// insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset.
// usdToEtbRate fetched live from CurrencyExchangeRate table.
// Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee.
const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100;
insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
? nationalitySeatClass.insuranceFeeMinor / 100
: 1;
usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = nationalitySeatClass.baseFareMinor; ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); baseFarePerPassengerMinor = Math.round(
totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
);
fareSource = 'SEAT_CLASS_BASE_FARE'; fareSource = 'SEAT_CLASS_BASE_FARE';
insuranceAlreadyInBase = true;
} }
// Premium and insurance fees applied per passenger const premiumPerPassenger = seatClass.premiumMinor ?? 0;
const premiumPerPassenger = seatClass.premiumMinor ?? 0; const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0);
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0; const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1; const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0; const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, adultCount); const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
// Subtotal includes: (distance-based fare + premium + insurance) × passengers const adultSubtotal = farePerPassengerMinor * adultCount;
// First child is free, but pays premium and insurance
const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0; let discountMinor = 0;
let promoLabel = 'none'; let promoLabel = 'none';
@@ -152,8 +157,7 @@ export class FareEngineService {
} }
} }
const afterDiscountMinor = subtotalMinor - discountMinor; const totalEtbMinor = subtotalMinor - discountMinor;
const totalEtbMinor = afterDiscountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -162,8 +166,10 @@ export class FareEngineService {
const calculation = [ const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`, `Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`, `Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`,
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, `Rate per km: ${nationalitySeatClass.baseFareMinor} minor ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -191,6 +197,8 @@ export class FareEngineService {
seatClassName: nationalitySeatClass.name, seatClassName: nationalitySeatClass.name,
totalDistanceKm, totalDistanceKm,
ratePerKmMinor, ratePerKmMinor,
insuranceFactor,
usdToEtbRate,
baseFarePerPassengerMinor, baseFarePerPassengerMinor,
premiumPerPassenger, premiumPerPassenger,
insurancePerPassenger, insurancePerPassenger,
@@ -323,19 +331,16 @@ export class FareEngineService {
if (fareRules.length > 0) { if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality); const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => { return fareRules.map(rule => ({
const seatClassId = rule.seatClassId; seatClassId: rule.seatClassId,
return { seatClassName: 'Unknown',
seatClassId, baseFareMinor: rule.baseFareMinor,
seatClassName: 'Unknown', totalMinor: rule.baseFareMinor,
baseFareMinor: rule.baseFareMinor, billingCurrency,
totalMinor: rule.baseFareMinor, totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
billingCurrency, exchangeRate,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), source: 'FARE_RULE',
exchangeRate, }));
source: 'FARE_RULE',
};
});
} }
throw new BadRequestException( throw new BadRequestException(

View File

@@ -72,9 +72,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations); const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1); expect(result.successful).toBe(1);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.duplicate).toBe(0);
}); });
it('should detect duplicate validations', async () => { it('should detect duplicate validations', async () => {
@@ -98,8 +97,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations); const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1); expect(result.successful).toBe(1);
expect(result.duplicate).toBe(1); expect(result.failed).toBe(1);
}); });
it('should handle already validated tickets', async () => { it('should handle already validated tickets', async () => {
@@ -119,8 +118,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations); const result = await service.validateOfflineBatch(validations);
expect(result.duplicate).toBe(1); expect(result.successful).toBe(0);
expect(result.success).toBe(0); expect(result.failed).toBe(1);
}); });
}); });
}); });

View File

@@ -42,7 +42,7 @@ export default function SupportPage() {
const { data, isLoading } = useConversations( const { data, isLoading } = useConversations(
status === 'ALL' ? { search } : { status, search }, status === 'ALL' ? { search } : { status, search },
); );
const items = data?.items ?? []; const items = useMemo(() => data?.items ?? [], [data?.items]);
useSupportSocket(true); useSupportSocket(true);

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,9 @@ const searchSchema = z
returnDate: z.string().optional(), returnDate: z.string().optional(),
adultCount: z.number().min(1).max(9), adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9), childCount: z.number().min(0).max(9),
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"]), nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
errorMap: () => ({ message: "Please select your nationality" }),
}),
promoCode: z.string().optional(), promoCode: z.string().optional(),
}) })
.refine( .refine(
@@ -226,6 +228,7 @@ function PassengerModal({
onChangeChild, onChangeChild,
onChangeNationality, onChangeNationality,
onClose, onClose,
nationalityError,
}: { }: {
adultCount: number; adultCount: number;
childCount: number; childCount: number;
@@ -234,6 +237,7 @@ function PassengerModal({
onChangeChild: (n: number) => void; onChangeChild: (n: number) => void;
onChangeNationality: (v: string) => void; onChangeNationality: (v: string) => void;
onClose: () => void; onClose: () => void;
nationalityError?: string;
}) { }) {
const rows = [ const rows = [
{ {
@@ -323,10 +327,13 @@ function PassengerModal({
</div> </div>
))} ))}
<div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5"> <div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5">
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3"> <p className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Nationality Nationality
</p> </p>
<div className="grid grid-cols-3 gap-2"> {nationalityError && (
<p className="text-xs text-red-500 mb-2">{nationalityError}</p>
)}
<div className={`grid grid-cols-3 gap-2 ${nationalityError ? "mt-1" : "mt-2"}`}>
{natOptions.map((opt) => ( {natOptions.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -335,7 +342,9 @@ function PassengerModal({
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${ className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
nationality === opt.value nationality === opt.value
? "border-primary bg-primary/5 text-primary" ? "border-primary bg-primary/5 text-primary"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300" : nationalityError
? "border-red-300 dark:border-red-800 text-gray-600 dark:text-gray-400 hover:border-gray-300"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300"
}`} }`}
> >
{opt.label} {opt.label}
@@ -586,7 +595,10 @@ export default function SearchPage() {
tripType: "ONE_WAY", tripType: "ONE_WAY",
adultCount: 1, adultCount: 1,
childCount: 0, childCount: 0,
nationality: "ETHIOPIAN", // No default nationality — the user must explicitly pick one. Left blank (not a valid
// enum member) so the zod schema's errorMap flags it if they try to search without
// selecting it.
nationality: "" as any,
departureDate: "", departureDate: "",
promoCode: "", promoCode: "",
}, },
@@ -716,10 +728,34 @@ export default function SearchPage() {
router.push(`/booking/results?${params}`); router.push(`/booking/results?${params}`);
}; };
// Validate the rest of the form first — only once every other field is already valid do
// we surface the nationality error (opening the modal directly rather than leaving an
// inline error to hunt for). Otherwise nationality's error would show at the same time as
// origin/destination/date errors, which is noisier than fixing things one step at a time.
const onInvalid = (formErrors: typeof errors) => {
setHasInteracted(true);
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
if (formErrors.nationality && !hasOtherErrors) {
setPassengerModalOpen(true);
}
};
const getStationById = (id: string) => stations.find((s) => s.id === id); const getStationById = (id: string) => stations.find((s) => s.id === id);
const originStation = getStationById(originId); const originStation = getStationById(originId);
const destStation = getStationById(destId); const destStation = getStationById(destId);
// Mirrors onInvalid's ordering: don't flag nationality (border/message/modal) while other
// fields still have errors of their own to fix first.
const showNationalityError =
hasInteracted &&
!!errors.nationality &&
!Object.keys(errors).some((k) => k !== "nationality");
// No default nationality anymore — only render a flag once one is actually picked, rather
// than falling through to the "Other" 🌍 flag and implying a selection that hasn't happened.
const nationalityFlag = (nat?: string) =>
nat === "ETHIOPIAN" ? "🇪🇹" : nat === "DJIBOUTIAN" ? "🇩🇯" : nat === "OTHER" ? "🌍" : null;
return ( return (
<div className="bg-gray-50 dark:bg-gray-950"> <div className="bg-gray-50 dark:bg-gray-950">
{/* Passenger modal (mobile) */} {/* Passenger modal (mobile) */}
@@ -730,8 +766,12 @@ export default function SearchPage() {
nationality={watch("nationality")} nationality={watch("nationality")}
onChangeAdult={(n) => setValue("adultCount", n)} onChangeAdult={(n) => setValue("adultCount", n)}
onChangeChild={(n) => setValue("childCount", n)} onChangeChild={(n) => setValue("childCount", n)}
onChangeNationality={(v) => setValue("nationality", v as any)} onChangeNationality={(v) => {
setValue("nationality", v as any);
clearErrors("nationality");
}}
onClose={() => setPassengerModalOpen(false)} onClose={() => setPassengerModalOpen(false)}
nationalityError={showNationalityError ? errors.nationality?.message : undefined}
/> />
)} )}
@@ -818,7 +858,7 @@ export default function SearchPage() {
ref={widgetRef} ref={widgetRef}
> >
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit, onInvalid)}>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible"> <div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
{error && ( {error && (
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl"> <div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
@@ -1002,19 +1042,22 @@ export default function SearchPage() {
<button <button
type="button" type="button"
onClick={() => setPassengerModalOpen(true)} onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white" className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white ${
showNationalityError ? "border-red-400" : "border-gray-200"
}`}
> >
<span className="flex items-center gap-2 text-sm font-medium text-gray-900"> <span className="flex items-center gap-2 text-sm font-medium text-gray-900">
<Users className="w-4 h-4 text-primary" /> <Users className="w-4 h-4 text-primary" />
{totalPassengers} Pax ·{" "} {totalPassengers} Pax
{watch("nationality") === "ETHIOPIAN" {nationalityFlag(watch("nationality"))
? "🇪🇹" ? ` · ${nationalityFlag(watch("nationality"))}`
: watch("nationality") === "DJIBOUTIAN" : " · Select nationality"}
? "🇩🇯"
: "🌍"}
</span> </span>
<ChevronDown className="w-4 h-4 text-primary" /> <ChevronDown className="w-4 h-4 text-primary" />
</button> </button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
@@ -1141,19 +1184,22 @@ export default function SearchPage() {
<button <button
type="button" type="button"
onClick={() => setPassengerModalOpen(true)} onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all" className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white hover:border-gray-300 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200"
}`}
> >
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate"> <span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 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} Pax
{watch("nationality") === "ETHIOPIAN" {nationalityFlag(watch("nationality"))
? "🇪🇹" ? ` · ${nationalityFlag(watch("nationality"))}`
: watch("nationality") === "DJIBOUTIAN" : " · Select 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>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
</div> </div>
{/* Search */} {/* Search */}
<button <button
@@ -1395,19 +1441,24 @@ export default function SearchPage() {
<button <button
type="button" type="button"
onClick={() => setPassengerModalOpen(true)} onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all" 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"> <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} Pax
{watch("nationality") === "ETHIOPIAN" {nationalityFlag(watch("nationality"))
? "🇪🇹" ? ` · ${nationalityFlag(watch("nationality"))}`
: watch("nationality") === "DJIBOUTIAN" : " · Select 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>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
</div> </div>
{/* Search Button */} {/* Search Button */}
<div className="flex-shrink-0 space-y-1"> <div className="flex-shrink-0 space-y-1">