mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
Merge pull request #520 from Tria-plc/alpha
Set nationality required on booking widget
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user