Update passenger contact information

This commit is contained in:
Roba Boru
2026-07-06 03:15:43 +03:00
parent 44306880e1
commit c852f99a3a

View File

@@ -529,6 +529,10 @@ const passengerSchema = z.object({
dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.string().min(1, 'Gender is required'),
nationality: z.string().min(1, 'Nationality is required'),
// Adults enter these themselves; children inherit the primary adult's values (see the
// sync effect in the page component) rather than collecting their own.
phone: z.string(),
email: z.string(),
nationalId: z.string().optional(),
passportNumber: z.string().optional(),
passportCountry: z.string().optional(),
@@ -557,27 +561,29 @@ function createFormSchema(adultCount: number) {
return z.object({
passengers: z.array(passengerSchema),
createAccount: z.boolean(),
contactPhone: z.string(),
contactEmail: z.string(),
}).superRefine((data, ctx) => {
if (!data.contactEmail || data.contactEmail.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Contact email is required', path: ['contactEmail'] });
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.contactEmail)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['contactEmail'] });
}
}
const contactNationality = data.passengers[0]?.nationality || 'ETHIOPIAN';
const phoneError = validatePhone(data.contactPhone, contactNationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['contactPhone'] });
}
data.passengers.forEach((p, i) => {
const isAdult = i < adultCount;
// Contact fields are only collected from — and validated against — adults.
// Children's phone/email are inherited from the primary adult, not user-entered.
if (isAdult) {
if (!p.email || p.email.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required', path: ['passengers', i, 'email'] });
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(p.email)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] });
}
}
const phoneError = validatePhone(p.phone, p.nationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['passengers', i, 'phone'] });
}
}
const age = calculateAge(p.dateOfBirth);
if (age === null) return;
const isAdult = i < adultCount;
if (isAdult && age <= 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] });
} else if (!isAdult && age > 5) {
@@ -623,6 +629,8 @@ export default function PassengersPage() {
dateOfBirth: stored.dateOfBirth || '',
gender: (stored.gender as any) || undefined,
nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN',
phone: (i >= adultCount ? storedPassengers[0]?.phone : stored.phone) || '',
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
nationalId: stored.nationalId || '',
passportNumber: stored.passportNumber || '',
passportCountry: stored.passportCountry || '',
@@ -638,6 +646,10 @@ export default function PassengersPage() {
dateOfBirth: '',
gender: undefined,
nationality: searchCriteria?.nationality || 'ETHIOPIAN',
// Children start out mirroring whatever the primary adult already has on file;
// the sync effect below keeps this current as the primary adult's info changes.
phone: (i >= adultCount ? storedPassengers[0]?.phone : '') || '',
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
nationalId: '',
passportNumber: '',
passportCountry: '',
@@ -649,14 +661,28 @@ export default function PassengersPage() {
};
}),
createAccount: false,
contactPhone: storedPassengers[0]?.phone || '',
contactEmail: storedPassengers[0]?.email || '',
},
});
const { fields } = useFieldArray({ control, name: 'passengers' });
const passengers = watch('passengers');
// Children don't collect their own contact info — keep their phone/email mirrored to
// whatever the primary adult (index 0) currently has, so it's always in sync.
const primaryPhone = passengers[0]?.phone;
const primaryEmail = passengers[0]?.email;
useEffect(() => {
for (let i = adultCount; i < passengers.length; i++) {
if (passengers[i]?.phone !== (primaryPhone || '')) {
setValue(`passengers.${i}.phone`, primaryPhone || '', { shouldValidate: true });
}
if (passengers[i]?.email !== (primaryEmail || '')) {
setValue(`passengers.${i}.email`, primaryEmail || '', { shouldValidate: true });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [primaryPhone, primaryEmail, adultCount, passengers.length]);
useEffect(() => {
const checkFaydaStatus = async () => {
try {
@@ -719,10 +745,9 @@ export default function PassengersPage() {
const normalizedGender = normalizeFaydaGender(d.gender);
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
// Contact info is shared across all passengers — only fill it in if nobody has
// entered it yet, so verifying passenger 2 can't clobber passenger 1's contact.
if (d.email && !watch('contactEmail')) setValue('contactEmail', d.email, { shouldValidate: true });
if (d.phoneNumber && !watch('contactPhone')) setValue('contactPhone', d.phoneNumber, { shouldValidate: true });
// Only fill in this passenger's own contact fields if they haven't entered them yet.
if (d.email && !watch(`passengers.${targetIndex}.email`)) setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
setValue(`passengers.${targetIndex}.faydaVerified`, true);
setValue(`passengers.${targetIndex}.formExpanded`, true);
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
@@ -781,8 +806,8 @@ export default function PassengersPage() {
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
if (passengerData?.phone || user.phone) setValue('contactPhone', passengerData?.phone || user.phone || '');
if (passengerData?.email || user.email) setValue('contactEmail', passengerData?.email || user.email || '');
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);
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
@@ -935,8 +960,8 @@ export default function PassengersPage() {
passportIssueDate: p.passportIssueDate,
passportExpiryDate: p.passportExpiryDate,
passportIssuingAuthority: p.passportIssuingAuthority,
phone: data.contactPhone,
email: data.contactEmail,
phone: p.phone,
email: p.email,
isPrimaryPassenger: i === 0,
passengerId: i === 0 && passengerId ? passengerId : undefined,
}))
@@ -1155,6 +1180,40 @@ export default function PassengersPage() {
disabled
/>
</div>
{isChildPassenger ? (
<div className="md:col-span-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400">
Contact details (phone &amp; email) are shared with the primary passenger and don&apos;t need to be entered separately.
</div>
) : (
<>
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</>
)}
</div>
</>
) : (
@@ -1210,6 +1269,40 @@ export default function PassengersPage() {
disabled
/>
</div>
{isChildPassenger ? (
<div className="md:col-span-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400">
Contact details (phone &amp; email) are shared with the primary passenger and don&apos;t need to be entered separately.
</div>
) : (
<>
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[index]?.nationality || 'OTHER'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</>
)}
</div>
{/* Passport fields */}
@@ -1267,40 +1360,6 @@ export default function PassengersPage() {
);
})}
<div className="card">
<h3 className="text-lg font-semibold mb-1 text-gray-900 dark:text-gray-100">Contact Information</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
This phone number and email will be used for booking and ticketing communication for all passengers.
</p>
<div className="grid md:grid-cols-2 gap-4">
{/* Contact Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[0]?.nationality || searchCriteria?.nationality || 'ETHIOPIAN'}
storedValue={watch('contactPhone') || ''}
onInterimChange={(v) => setValue('contactPhone', v)}
onNormalized={(v) => setValue('contactPhone', v, { shouldValidate: true })}
error={errors.contactPhone?.message}
/>
</div>
{/* Contact Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<input
type="email"
{...register('contactEmail')}
className={`input-field ${errors.contactEmail ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.contactEmail && (
<p className="text-red-500 text-xs mt-1">{errors.contactEmail.message}</p>
)}
</div>
</div>
</div>
{!isAuthenticated && (
<div className="card">
<label className="flex items-center gap-2 cursor-pointer">