mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +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);
|
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
|
// Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
|
||||||
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
|
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
|
||||||
const g = String(raw || '').trim().toUpperCase();
|
const g = String(raw || '').trim().toUpperCase();
|
||||||
@@ -85,11 +115,13 @@ function DobPickerModal({
|
|||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
passengerType = 'ADULT',
|
passengerType = 'ADULT',
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (iso: string) => void;
|
onChange: (iso: string) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
passengerType?: 'ADULT' | 'CHILD';
|
passengerType?: 'ADULT' | 'CHILD';
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [manualMode, setManualMode] = useState(false);
|
const [manualMode, setManualMode] = useState(false);
|
||||||
@@ -286,9 +318,10 @@ function DobPickerModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
className={`input-field w-full text-left flex items-center justify-between ${
|
className={`input-field w-full text-left flex items-center justify-between ${
|
||||||
error ? 'border-red-500' : ''
|
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'}>
|
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
|
||||||
{displayValue || 'Select date of birth'}
|
{displayValue || 'Select date of birth'}
|
||||||
@@ -470,12 +503,14 @@ function PhoneInput({
|
|||||||
onInterimChange,
|
onInterimChange,
|
||||||
onNormalized,
|
onNormalized,
|
||||||
error,
|
error,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
nationality: string;
|
nationality: string;
|
||||||
storedValue: string;
|
storedValue: string;
|
||||||
onInterimChange: (full: string) => void;
|
onInterimChange: (full: string) => void;
|
||||||
onNormalized: (full: string) => void;
|
onNormalized: (full: string) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const nat = getPhoneNat(nationality);
|
const nat = getPhoneNat(nationality);
|
||||||
const preset = PHONE_PRESETS[nat];
|
const preset = PHONE_PRESETS[nat];
|
||||||
@@ -519,7 +554,10 @@ function PhoneInput({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={preset.example}
|
placeholder={preset.example}
|
||||||
autoComplete="tel"
|
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>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -565,6 +603,10 @@ const passengerSchema = z.object({
|
|||||||
passportIssuingAuthority: z.string().optional(),
|
passportIssuingAuthority: z.string().optional(),
|
||||||
faydaVerified: z.boolean().optional(),
|
faydaVerified: z.boolean().optional(),
|
||||||
faydaSub: z.string().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(),
|
formExpanded: z.boolean().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||||
@@ -635,7 +677,7 @@ function createFormSchema(adultCount: number) {
|
|||||||
|
|
||||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||||
|
|
||||||
export default function PassengersPage() {
|
function PassengersForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
|
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
|
||||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||||
@@ -662,6 +704,11 @@ export default function PassengersPage() {
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
passengers: Array.from({ length: totalPassengers }, (_, i) => {
|
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];
|
const stored = storedPassengers[i];
|
||||||
if (stored?.name) {
|
if (stored?.name) {
|
||||||
return {
|
return {
|
||||||
@@ -700,7 +747,7 @@ export default function PassengersPage() {
|
|||||||
formExpanded: i >= adultCount,
|
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.
|
// 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
|
// 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
|
// 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)}`
|
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.success && response?.data?.verified) {
|
// apiClient already unwraps the { success, data } envelope, so `response` is the
|
||||||
const d = response.data;
|
// verification result itself.
|
||||||
|
const d = response;
|
||||||
|
if (d?.verified) {
|
||||||
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
|
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
|
||||||
|
|
||||||
// A single Fayda identity can't be reused across two different passengers.
|
// 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 (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
|
||||||
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
|
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
|
||||||
// Only fill in this passenger's own contact fields if they haven't entered them yet.
|
// 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.email && !watch(`passengers.${targetIndex}.email`)) {
|
||||||
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
|
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}.faydaVerified`, true);
|
||||||
setValue(`passengers.${targetIndex}.formExpanded`, true);
|
setValue(`passengers.${targetIndex}.formExpanded`, true);
|
||||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
|
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
|
||||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
|
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
|
||||||
|
|
||||||
|
if (targetIndex === 0 && isAuthenticated) {
|
||||||
|
updateUser({ fullName: d.fullName, faydaVerified: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||||
@@ -824,6 +903,15 @@ export default function PassengersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const populateForm = async () => {
|
const populateForm = async () => {
|
||||||
if (!isInitialized) return;
|
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) {
|
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||||
setFormInitialized(true);
|
setFormInitialized(true);
|
||||||
return;
|
return;
|
||||||
@@ -864,16 +952,19 @@ export default function PassengersPage() {
|
|||||||
|
|
||||||
const openFaydaVerification = async (index: number) => {
|
const openFaydaVerification = async (index: number) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
// Only one passenger can verify at a time — this keeps the status poll below
|
// Only one passenger can verify at a time so the returning ?code&state is unambiguously
|
||||||
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
|
// applied to the passenger who started it.
|
||||||
if (verifyingIndex !== null) return;
|
if (verifyingIndex !== null) return;
|
||||||
|
|
||||||
setVerifyingIndex(index);
|
setVerifyingIndex(index);
|
||||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
|
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
|
||||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||||
// Persist which passenger this is for so it survives a full-page redirect/reload
|
// Stash the verifying passenger index and a full snapshot of the in-progress form. Both live
|
||||||
// if the browser can't open a popup (e.g. some mobile browsers).
|
// 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);
|
setPendingFaydaIndex(index);
|
||||||
|
saveFaydaFormSnapshot({ passengers: watch('passengers'), createAccount: watch('createAccount') });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||||
@@ -881,78 +972,16 @@ export default function PassengersPage() {
|
|||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
saveToAccount: index === 0 && isAuthenticated,
|
saveToAccount: index === 0 && isAuthenticated,
|
||||||
});
|
});
|
||||||
|
// Redirect the whole tab to eSignet. Fayda returns the browser to this same route
|
||||||
const authorizationUrl = response.authorizationUrl;
|
// (FAYDA_WEB_REDIRECT_URI = <portal>/booking/passengers) with ?code&state, which the
|
||||||
const width = 600;
|
// completion effect above picks up on mount.
|
||||||
const height = 700;
|
window.location.href = response.authorizationUrl;
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
||||||
setVerifyingIndex(null);
|
setVerifyingIndex(null);
|
||||||
clearPendingFaydaIndex();
|
clearPendingFaydaIndex();
|
||||||
|
clearFaydaFormSnapshot();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1025,12 +1054,8 @@ export default function PassengersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// searchCriteria is guaranteed present here — the PassengersPage gate below only mounts this
|
||||||
if (!searchCriteria) {
|
// component after the persisted booking store has rehydrated and confirmed a booking exists.
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
}, [searchCriteria, router]);
|
|
||||||
|
|
||||||
if (!searchCriteria) return null;
|
if (!searchCriteria) return null;
|
||||||
|
|
||||||
if (!formInitialized || faydaCompleting) {
|
if (!formInitialized || faydaCompleting) {
|
||||||
@@ -1070,6 +1095,13 @@ export default function PassengersPage() {
|
|||||||
const isVerifyingThis = verifyingIndex === index;
|
const isVerifyingThis = verifyingIndex === index;
|
||||||
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
|
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
|
||||||
const faydaError = faydaErrors[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 (
|
return (
|
||||||
<div key={field.id} className="card">
|
<div key={field.id} className="card">
|
||||||
@@ -1148,7 +1180,7 @@ export default function PassengersPage() {
|
|||||||
{status === 'success' && (
|
{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">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...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"
|
placeholder="Full name as per ID"
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
@@ -1181,20 +1214,29 @@ export default function PassengersPage() {
|
|||||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||||
|
disabled={isFaydaLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
{isFaydaLocked ? (
|
||||||
{...register(`passengers.${index}.gender`)}
|
<input
|
||||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
value={passengers[index]?.gender || ''}
|
||||||
>
|
readOnly
|
||||||
<option value="">Select gender</option>
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
<option value="Male">Male</option>
|
/>
|
||||||
<option value="Female">Female</option>
|
) : (
|
||||||
</select>
|
<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 && (
|
{errors.passengers?.[index]?.gender && (
|
||||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
<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)}
|
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.phone?.message}
|
error={errors.passengers?.[index]?.phone?.message}
|
||||||
|
disabled={isPhoneLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1235,7 +1278,8 @@ export default function PassengersPage() {
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.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"
|
placeholder="email@example.com"
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
@@ -1270,20 +1314,29 @@ export default function PassengersPage() {
|
|||||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||||
|
disabled={isFaydaLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
{isFaydaLocked ? (
|
||||||
{...register(`passengers.${index}.gender`)}
|
<input
|
||||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
value={passengers[index]?.gender || ''}
|
||||||
>
|
readOnly
|
||||||
<option value="">Select gender</option>
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
<option value="Male">Male</option>
|
/>
|
||||||
<option value="Female">Female</option>
|
) : (
|
||||||
</select>
|
<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 && (
|
{errors.passengers?.[index]?.gender && (
|
||||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1324,7 +1377,8 @@ export default function PassengersPage() {
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.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"
|
placeholder="email@example.com"
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
@@ -1446,3 +1500,40 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</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