mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Set nationality required on booking widget
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,9 @@ const searchSchema = z
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).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(),
|
||||
})
|
||||
.refine(
|
||||
@@ -226,6 +228,7 @@ function PassengerModal({
|
||||
onChangeChild,
|
||||
onChangeNationality,
|
||||
onClose,
|
||||
nationalityError,
|
||||
}: {
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
@@ -234,6 +237,7 @@ function PassengerModal({
|
||||
onChangeChild: (n: number) => void;
|
||||
onChangeNationality: (v: string) => void;
|
||||
onClose: () => void;
|
||||
nationalityError?: string;
|
||||
}) {
|
||||
const rows = [
|
||||
{
|
||||
@@ -323,10 +327,13 @@ function PassengerModal({
|
||||
</div>
|
||||
))}
|
||||
<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
|
||||
</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) => (
|
||||
<button
|
||||
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 ${
|
||||
nationality === opt.value
|
||||
? "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}
|
||||
@@ -586,7 +595,10 @@ export default function SearchPage() {
|
||||
tripType: "ONE_WAY",
|
||||
adultCount: 1,
|
||||
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: "",
|
||||
promoCode: "",
|
||||
},
|
||||
@@ -716,10 +728,34 @@ export default function SearchPage() {
|
||||
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 originStation = getStationById(originId);
|
||||
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 (
|
||||
<div className="bg-gray-50 dark:bg-gray-950">
|
||||
{/* Passenger modal (mobile) */}
|
||||
@@ -730,8 +766,12 @@ export default function SearchPage() {
|
||||
nationality={watch("nationality")}
|
||||
onChangeAdult={(n) => setValue("adultCount", 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)}
|
||||
nationalityError={showNationalityError ? errors.nationality?.message : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -818,7 +858,7 @@ export default function SearchPage() {
|
||||
ref={widgetRef}
|
||||
>
|
||||
<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">
|
||||
{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">
|
||||
@@ -1002,19 +1042,22 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
{totalPassengers} Pax ·{" "}
|
||||
{watch("nationality") === "ETHIOPIAN"
|
||||
? "🇪🇹"
|
||||
: watch("nationality") === "DJIBOUTIAN"
|
||||
? "🇩🇯"
|
||||
: "🌍"}
|
||||
{totalPassengers} Pax
|
||||
{nationalityFlag(watch("nationality"))
|
||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||
: " · Select nationality"}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary" />
|
||||
</button>
|
||||
{showNationalityError && (
|
||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -1141,19 +1184,22 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax ·{" "}
|
||||
{watch("nationality") === "ETHIOPIAN"
|
||||
? "🇪🇹"
|
||||
: watch("nationality") === "DJIBOUTIAN"
|
||||
? "🇩🇯"
|
||||
: "🌍"}
|
||||
{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
|
||||
@@ -1395,19 +1441,24 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax ·{" "}
|
||||
{watch("nationality") === "ETHIOPIAN"
|
||||
? "🇪🇹"
|
||||
: watch("nationality") === "DJIBOUTIAN"
|
||||
? "🇩🇯"
|
||||
: "🌍"}
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user