mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
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 />;
|
||||
}
|
||||
|
||||
@@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.',
|
||||
},
|
||||
{
|
||||
question: 'How do I modify my booking?',
|
||||
answer:
|
||||
'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.',
|
||||
},
|
||||
{
|
||||
question: 'What is the cancellation policy?',
|
||||
answer:
|
||||
'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -84,28 +74,13 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
{
|
||||
question: 'What are the passenger age categories?',
|
||||
answer:
|
||||
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.',
|
||||
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child per adult travels free, and any additional children pay the full fare.',
|
||||
},
|
||||
{
|
||||
question: 'How is a child\'s age determined?',
|
||||
answer:
|
||||
'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.',
|
||||
},
|
||||
{
|
||||
question: 'Example: how much does a family of 2 adults + 3 children pay?',
|
||||
answer:
|
||||
'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.',
|
||||
},
|
||||
{
|
||||
question: 'What seat classes are available?',
|
||||
answer:
|
||||
'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.',
|
||||
},
|
||||
{
|
||||
question: 'What is the nationality field for?',
|
||||
answer:
|
||||
'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -153,11 +128,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.',
|
||||
},
|
||||
{
|
||||
question: 'Are prices shown in my local currency?',
|
||||
answer:
|
||||
'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -167,22 +137,12 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
{
|
||||
question: 'What payment methods are accepted?',
|
||||
answer:
|
||||
'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.',
|
||||
},
|
||||
{
|
||||
question: 'What is the EDR Wallet?',
|
||||
answer:
|
||||
'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.',
|
||||
},
|
||||
{
|
||||
question: 'When will I receive my refund?',
|
||||
answer:
|
||||
'Refunds are processed within 5–7 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.',
|
||||
'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. You can choose your preferred method at checkout.',
|
||||
},
|
||||
{
|
||||
question: 'Is my payment information secure?',
|
||||
answer:
|
||||
'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
|
||||
'Yes. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -226,16 +186,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.',
|
||||
},
|
||||
{
|
||||
question: 'How do I set up Verifayda on my account?',
|
||||
answer:
|
||||
'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.',
|
||||
},
|
||||
{
|
||||
question: 'Can I use the app in multiple languages?',
|
||||
answer:
|
||||
'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -12,27 +12,11 @@ export default function ThemeToggle() {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
} else if (theme === 'dark') {
|
||||
setTheme('system');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
};
|
||||
const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
|
||||
const getIcon = () => {
|
||||
if (theme === 'light') return <Sun className="w-5 h-5" />;
|
||||
if (theme === 'dark') return <Moon className="w-5 h-5" />;
|
||||
return <Monitor className="w-5 h-5" />;
|
||||
};
|
||||
const getIcon = () => theme === 'dark' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />;
|
||||
|
||||
const getLabel = () => {
|
||||
if (theme === 'light') return 'Light';
|
||||
if (theme === 'dark') return 'Dark';
|
||||
return 'System';
|
||||
};
|
||||
const getLabel = () => theme === 'dark' ? 'Dark' : 'Light';
|
||||
|
||||
// Prevent hydration mismatch by not rendering until mounted
|
||||
if (!mounted) {
|
||||
|
||||
Reference in New Issue
Block a user