mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: ( fayda ) implement fayda for passengers
This commit is contained in:
@@ -72,6 +72,36 @@ function clearPendingFaydaIndex() {
|
||||
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY);
|
||||
}
|
||||
|
||||
// Verification is a full-page redirect out to Fayda and back (same flow on desktop and mobile —
|
||||
// no popup). The in-progress form only lives in React memory, which the reload wipes, so we
|
||||
// snapshot it to sessionStorage before leaving and restore it (in the form's defaultValues) on
|
||||
// return. sessionStorage survives a same-tab navigation, including the cross-origin round trip.
|
||||
const FAYDA_FORM_SNAPSHOT_KEY = 'edr_fayda_form_snapshot';
|
||||
|
||||
function saveFaydaFormSnapshot(snapshot: unknown) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.sessionStorage.setItem(FAYDA_FORM_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// sessionStorage full/unavailable — verification still works, only unsaved fields are lost.
|
||||
}
|
||||
}
|
||||
|
||||
function getFaydaFormSnapshot(): { passengers?: any[]; createAccount?: boolean } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(FAYDA_FORM_SNAPSHOT_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFaydaFormSnapshot() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.removeItem(FAYDA_FORM_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
// Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
|
||||
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
|
||||
const g = String(raw || '').trim().toUpperCase();
|
||||
@@ -85,11 +115,13 @@ function DobPickerModal({
|
||||
onChange,
|
||||
error,
|
||||
passengerType = 'ADULT',
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
error?: string;
|
||||
passengerType?: 'ADULT' | 'CHILD';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
@@ -286,9 +318,10 @@ function DobPickerModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
className={`input-field w-full text-left flex items-center justify-between ${
|
||||
error ? 'border-red-500' : ''
|
||||
}`}
|
||||
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
|
||||
{displayValue || 'Select date of birth'}
|
||||
@@ -470,12 +503,14 @@ function PhoneInput({
|
||||
onInterimChange,
|
||||
onNormalized,
|
||||
error,
|
||||
disabled = false,
|
||||
}: {
|
||||
nationality: string;
|
||||
storedValue: string;
|
||||
onInterimChange: (full: string) => void;
|
||||
onNormalized: (full: string) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const nat = getPhoneNat(nationality);
|
||||
const preset = PHONE_PRESETS[nat];
|
||||
@@ -519,7 +554,10 @@ function PhoneInput({
|
||||
onBlur={handleBlur}
|
||||
placeholder={preset.example}
|
||||
autoComplete="tel"
|
||||
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
|
||||
readOnly={disabled}
|
||||
className={`flex-1 px-3 py-2.5 text-sm text-gray-900 dark:text-white outline-none min-w-0 ${
|
||||
disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
@@ -565,6 +603,10 @@ const passengerSchema = z.object({
|
||||
passportIssuingAuthority: z.string().optional(),
|
||||
faydaVerified: z.boolean().optional(),
|
||||
faydaSub: z.string().optional(),
|
||||
// Set when the corresponding contact value was supplied by Fayda (vs typed by the user) —
|
||||
// a Fayda-supplied phone/email is locked; a field Fayda left blank stays editable.
|
||||
faydaEmailLocked: z.boolean().optional(),
|
||||
faydaPhoneLocked: z.boolean().optional(),
|
||||
formExpanded: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||
@@ -635,7 +677,7 @@ function createFormSchema(adultCount: number) {
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
export default function PassengersPage() {
|
||||
function PassengersForm() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
@@ -662,6 +704,11 @@ export default function PassengersPage() {
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
passengers: Array.from({ length: totalPassengers }, (_, i) => {
|
||||
// Returning from a Fayda redirect: restore the exact in-progress form we snapshotted
|
||||
// before leaving, so no passenger's typed data is lost. The completion effect then
|
||||
// applies the verified attributes on top for the passenger who initiated it.
|
||||
const snap = getFaydaFormSnapshot()?.passengers?.[i];
|
||||
if (snap) return snap;
|
||||
const stored = storedPassengers[i];
|
||||
if (stored?.name) {
|
||||
return {
|
||||
@@ -700,7 +747,7 @@ export default function PassengersPage() {
|
||||
formExpanded: i >= adultCount,
|
||||
};
|
||||
}),
|
||||
createAccount: false,
|
||||
createAccount: getFaydaFormSnapshot()?.createAccount ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -748,6 +795,26 @@ export default function PassengersPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The redirect snapshot is consumed once, during the form's defaultValues at mount. Clear it
|
||||
// afterwards so a later visit to this page doesn't restore stale data.
|
||||
useEffect(() => {
|
||||
clearFaydaFormSnapshot();
|
||||
}, []);
|
||||
|
||||
// Show the green "verified" banner for any passenger restored from a snapshot as already
|
||||
// Fayda-verified (verificationStatus is React state and doesn't survive the redirect).
|
||||
useEffect(() => {
|
||||
if (!formInitialized) return;
|
||||
setVerificationStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
passengers.forEach((p, i) => {
|
||||
if ((p as any)?.faydaVerified && !next[i]) next[i] = 'success';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formInitialized]);
|
||||
|
||||
// Complete Fayda verification once the form is ready and callback params are present.
|
||||
// This effect runs whenever this route reloads with ?code&state — which happens either
|
||||
// inside the verification popup, or, if the browser refused to open a popup, as a full
|
||||
@@ -764,8 +831,10 @@ export default function PassengersPage() {
|
||||
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
|
||||
);
|
||||
|
||||
if (response?.success && response?.data?.verified) {
|
||||
const d = response.data;
|
||||
// apiClient already unwraps the { success, data } envelope, so `response` is the
|
||||
// verification result itself.
|
||||
const d = response;
|
||||
if (d?.verified) {
|
||||
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
|
||||
|
||||
// A single Fayda identity can't be reused across two different passengers.
|
||||
@@ -786,12 +855,22 @@ export default function PassengersPage() {
|
||||
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
|
||||
// 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 });
|
||||
if (d.email && !watch(`passengers.${targetIndex}.email`)) {
|
||||
setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
|
||||
setValue(`passengers.${targetIndex}.faydaEmailLocked`, true);
|
||||
}
|
||||
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) {
|
||||
setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
|
||||
setValue(`passengers.${targetIndex}.faydaPhoneLocked`, true);
|
||||
}
|
||||
setValue(`passengers.${targetIndex}.faydaVerified`, true);
|
||||
setValue(`passengers.${targetIndex}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
|
||||
|
||||
if (targetIndex === 0 && isAuthenticated) {
|
||||
updateUser({ fullName: d.fullName, faydaVerified: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
@@ -824,6 +903,15 @@ export default function PassengersPage() {
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isInitialized) return;
|
||||
// Returning from a Fayda redirect (?code&state): the snapshot restore + completion effect
|
||||
// own the form here — don't overwrite passenger 0 with the profile fetch.
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('code') && params.get('state')) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
@@ -864,16 +952,19 @@ export default function PassengersPage() {
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Only one passenger can verify at a time — this keeps the status poll below
|
||||
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
|
||||
// Only one passenger can verify at a time so the returning ?code&state is unambiguously
|
||||
// applied to the passenger who started it.
|
||||
if (verifyingIndex !== null) return;
|
||||
|
||||
setVerifyingIndex(index);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
// Persist which passenger this is for so it survives a full-page redirect/reload
|
||||
// if the browser can't open a popup (e.g. some mobile browsers).
|
||||
// Stash the verifying passenger index and a full snapshot of the in-progress form. Both live
|
||||
// in sessionStorage, which survives the same-tab round trip out to Fayda and back — so no
|
||||
// typed data is lost and the callback is applied to the right passenger. Identical flow on
|
||||
// desktop and mobile: a full-page redirect, no popup and no window.opener dependency.
|
||||
setPendingFaydaIndex(index);
|
||||
saveFaydaFormSnapshot({ passengers: watch('passengers'), createAccount: watch('createAccount') });
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
@@ -881,78 +972,16 @@ export default function PassengersPage() {
|
||||
platform: 'WEB',
|
||||
saveToAccount: index === 0 && isAuthenticated,
|
||||
});
|
||||
|
||||
const authorizationUrl = response.authorizationUrl;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
'FaydaVerification',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Unable to open the Fayda verification window. Please allow pop-ups and try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkPopup = setInterval(async () => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkPopup);
|
||||
try {
|
||||
const statusResponse: any = await apiClient.get('/fayda/verification/status');
|
||||
if (statusResponse.verified) {
|
||||
const faydaSub: string | undefined = statusResponse.sub || statusResponse.faydaSub || statusResponse.fin;
|
||||
const usedByOther = faydaSub && passengers.some(
|
||||
(p, i) => i !== index && (p as any).faydaSub === faydaSub,
|
||||
);
|
||||
|
||||
if (usedByOther) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'This Fayda identity is already linked to another passenger on this booking.' }));
|
||||
} else {
|
||||
setValue(`passengers.${index}.name`, statusResponse.fullName || '', { shouldValidate: true });
|
||||
if (statusResponse.dateOfBirth) setValue(`passengers.${index}.dateOfBirth`, statusResponse.dateOfBirth, { shouldValidate: true });
|
||||
const normalizedGender = normalizeFaydaGender(statusResponse.gender);
|
||||
if (normalizedGender) setValue(`passengers.${index}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${index}.faydaSub`, faydaSub);
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
|
||||
if (index === 0 && isAuthenticated) {
|
||||
updateUser({
|
||||
fullName: statusResponse.fullName,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: statusResponse.verifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again.' }));
|
||||
} finally {
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
// Redirect the whole tab to eSignet. Fayda returns the browser to this same route
|
||||
// (FAYDA_WEB_REDIRECT_URI = <portal>/booking/passengers) with ?code&state, which the
|
||||
// completion effect above picks up on mount.
|
||||
window.location.href = response.authorizationUrl;
|
||||
} catch (error) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
clearFaydaFormSnapshot();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1025,12 +1054,8 @@ export default function PassengersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchCriteria) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [searchCriteria, router]);
|
||||
|
||||
// searchCriteria is guaranteed present here — the PassengersPage gate below only mounts this
|
||||
// component after the persisted booking store has rehydrated and confirmed a booking exists.
|
||||
if (!searchCriteria) return null;
|
||||
|
||||
if (!formInitialized || faydaCompleting) {
|
||||
@@ -1070,6 +1095,13 @@ export default function PassengersPage() {
|
||||
const isVerifyingThis = verifyingIndex === index;
|
||||
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
|
||||
const faydaError = faydaErrors[index];
|
||||
// Identity fields sourced from a completed Fayda verification are locked — the
|
||||
// passenger can't edit the verified name / date of birth / gender.
|
||||
const isFaydaLocked = !!passengers[index]?.faydaVerified;
|
||||
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
|
||||
// blank stays editable so the passenger can add their own phone/email.
|
||||
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked;
|
||||
const isEmailLocked = !!passengers[index]?.faydaEmailLocked;
|
||||
|
||||
return (
|
||||
<div key={field.id} className="card">
|
||||
@@ -1148,7 +1180,7 @@ export default function PassengersPage() {
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda — details auto-filled below
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda — verified details are locked below
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1165,7 +1197,8 @@ export default function PassengersPage() {
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
readOnly={isFaydaLocked}
|
||||
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
@@ -1181,20 +1214,29 @@ export default function PassengersPage() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{isFaydaLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
@@ -1226,6 +1268,7 @@ export default function PassengersPage() {
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
disabled={isPhoneLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1235,7 +1278,8 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
readOnly={isEmailLocked}
|
||||
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
@@ -1270,20 +1314,29 @@ export default function PassengersPage() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{isFaydaLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
@@ -1324,7 +1377,8 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
readOnly={isEmailLocked}
|
||||
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
@@ -1446,3 +1500,40 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate that waits for the persisted booking store to finish rehydrating from localStorage before
|
||||
* mounting the form. This matters on a full page load — notably returning from the Fayda redirect
|
||||
* (`/booking/passengers?code&state`) — where reading searchCriteria too early would (a) bounce to
|
||||
* home and (b) initialise react-hook-form with the wrong passenger count. Once hydrated: no
|
||||
* booking → redirect home; booking present → render the form with correct defaults.
|
||||
*/
|
||||
export default function PassengersPage() {
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (useBookingStore.persist.hasHydrated()) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const unsub = useBookingStore.persist.onFinishHydration(() => setHydrated(true));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && !searchCriteria) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [hydrated, searchCriteria]);
|
||||
|
||||
if (!hydrated || !searchCriteria) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PassengersForm />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user