Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-11 00:17:27 +03:00

View File

@@ -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 />;
}