Merge pull request #969 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-27 11:08:44 +03:00
committed by GitHub
11 changed files with 283 additions and 84 deletions

View File

@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
import { UserPlus, ChevronLeft } from 'lucide-react';
// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button
import { UserPlus, ChevronLeft, LogIn } from 'lucide-react';
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
const [visible, setVisible] = useState(false);
@@ -96,7 +95,6 @@ export default function AuthCheckPage() {
</button>
</Tooltip>
{/* TODO: re-enable auth — SignIn or Register button commented out until auth integration
<Tooltip content={[
'Saved passenger details',
'View booking history',
@@ -110,7 +108,6 @@ export default function AuthCheckPage() {
SignIn or Register
</button>
</Tooltip>
*/}
</div>
<div className="mt-8 text-center">

View File

@@ -933,27 +933,60 @@ function PassengersForm() {
}
try {
// Fetch passenger profile from backend
const passengerData: any = await apiClient.get(`/passengers/me`);
// Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked
// yet) — in that case fall back to the `user` object, which /auth/profile hydrates from
// the same IAM record (name, gender, DOB, nationality, Fayda status). Merging the two
// means an already-verified passenger's form still prefills from whichever source has
// the data, rather than being left blank.
const passengerData: any = (await apiClient.get(`/passengers/me`)) || {};
if (!passengerData) {
const pick = (a: any, b: any) => (a !== undefined && a !== null && a !== '' ? a : b);
// Nationality for THIS booking is the one chosen at search — the passengers-page
// nationality field is read-only. It, not the account's stored nationality, decides
// whether the Fayda gate applies, so a logged-in user who picked "Other"/"Djiboutian"
// isn't wrongly forced into Fayda (Fayda is only for Ethiopian nationals).
const nationality = searchCriteria?.nationality || pick(passengerData.nationality, user.nationality) || 'ETHIOPIAN';
const isEthiopian = String(nationality).toUpperCase() === 'ETHIOPIAN';
const isVerified = Boolean(pick(passengerData.faydaVerified, user.faydaVerified));
// A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a
// guest. Prefilling their identity and expanding the form would let them submit the
// booking without ever verifying — only a verified passenger may pass. When Fayda is
// globally disabled there is no gate, so the restriction doesn't apply.
const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled;
// Nationality + contact aren't identity-verifying, so they're safe to prefill either way.
setValue('passengers.0.nationality', nationality);
const phoneVal = pick(passengerData.phone, user.phone);
if (phoneVal) setValue('passengers.0.phone', phoneVal);
const emailVal = pick(passengerData.email, user.email);
if (emailVal) setValue('passengers.0.email', emailVal);
if (mustVerifyFayda) {
// Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the
// "Verify with Fayda" screen is shown instead of an editable, pre-filled form.
setValue('passengers.0.faydaVerified', false);
setValue('passengers.0.formExpanded', false);
setFormInitialized(true);
return;
}
// Only populate first passenger
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false);
// Verified Ethiopian, or a non-Ethiopian (passport flow): prefill everything and expand.
setValue('passengers.0.name', pick(passengerData.fullName, user.fullName) || '');
setValue('passengers.0.dateOfBirth', pick(passengerData.dateOfBirth, user.dateOfBirth) || '');
const genderVal = pick(passengerData.gender, user.gender);
if (genderVal) setValue('passengers.0.gender', genderVal as any);
const passportNumberVal = pick(passengerData.passportNumber, user.passportNumber);
if (passportNumberVal) setValue('passengers.0.passportNumber', passportNumberVal);
setValue('passengers.0.passportCountry', pick(passengerData.passportCountry, user.passportCountry) || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
const passportIssueVal = pick(passengerData.passportIssueDate, user.passportIssueDate);
if (passportIssueVal) setValue('passengers.0.passportIssueDate', passportIssueVal);
const passportExpiryVal = pick(passengerData.passportExpiryDate, user.passportExpiryDate);
if (passportExpiryVal) setValue('passengers.0.passportExpiryDate', passportExpiryVal);
const passportAuthVal = pick(passengerData.passportIssuingAuthority, user.passportIssuingAuthority);
if (passportAuthVal) setValue('passengers.0.passportIssuingAuthority', passportAuthVal);
setValue('passengers.0.faydaVerified', isVerified);
setValue('passengers.0.formExpanded', true);
setFormInitialized(true);
@@ -1126,10 +1159,21 @@ function PassengersForm() {
// 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;
// ...but lock each field only when it actually carries a value. A verified profile
// can be missing a field (e.g. a Fayda *login* record whose metadata has no date of
// birth) — locking an empty, required input would strand the user with no way to
// fill it or submit. A missing field stays editable so they can complete it.
const isNameLocked = isFaydaLocked && !!passengers[index]?.name;
const isDobLocked = isFaydaLocked && !!passengers[index]?.dateOfBirth;
const isGenderLocked = isFaydaLocked && !!passengers[index]?.gender;
// 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;
// A logged-in, already-verified primary passenger's contact details also come from
// their verified profile — lock those too, alongside name/DOB/gender. Only lock a
// field that actually has a value, so an incomplete profile can't strand the user
// on an unfillable required field.
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked || (isLoggedInAndVerified && !!passengers[index]?.phone);
const isEmailLocked = !!passengers[index]?.faydaEmailLocked || (isLoggedInAndVerified && !!passengers[index]?.email);
return (
<div key={field.id} className="card">
@@ -1227,8 +1271,8 @@ function PassengersForm() {
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
readOnly={isFaydaLocked}
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
readOnly={isNameLocked}
className={`input-field ${isNameLocked ? '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 && (
@@ -1244,14 +1288,14 @@ function PassengersForm() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
disabled={isDobLocked}
/>
</div>
{/* Gender */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
{isFaydaLocked ? (
{isGenderLocked ? (
<input
value={passengers[index]?.gender || ''}
readOnly
@@ -1344,14 +1388,14 @@ function PassengersForm() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
disabled={isDobLocked}
/>
</div>
{/* Gender */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
{isFaydaLocked ? (
{isGenderLocked ? (
<input
value={passengers[index]?.gender || ''}
readOnly
@@ -1398,6 +1442,7 @@ function PassengersForm() {
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>

View File

@@ -192,22 +192,20 @@ export default function AppSidebar() {
)}
</div>
) : (
// TODO: re-enable auth — Sign in / Register links commented out until auth integration
// <div className="flex items-center gap-2 px-1 pt-1">
// <Link
// href="/login"
// className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
// >
// Sign in
// </Link>
// <Link
// href="/register"
// className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
// >
// Register
// </Link>
// </div>
null
<div className="flex items-center gap-2 px-1 pt-1">
<Link
href="/login"
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
>
Sign in
</Link>
<Link
href="/register"
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
>
Register
</Link>
</div>
)}
</div>

View File

@@ -1,10 +1,9 @@
'use client';
import { Home, Phone, Ticket } from 'lucide-react';
// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab
import { Home, Phone, Ticket, User } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth
import { useAuthStore } from '@/lib/auth-store';
// The linear, one-screen-at-a-time booking flow — each of these pages already
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
@@ -22,7 +21,7 @@ const LINEAR_FLOW_PREFIXES = [
export default function BottomTabBar() {
const pathname = usePathname() ?? '';
// const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
if (isInLinearFlow) return null;
@@ -31,13 +30,12 @@ export default function BottomTabBar() {
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
// TODO: re-enable auth — auth login/register tab commented out until auth integration
// {
// href: isAuthenticated ? '/profile' : '/login',
// label: isAuthenticated ? 'Account' : 'Sign in',
// icon: User,
// match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
// },
{
href: isAuthenticated ? '/profile' : '/login',
label: isAuthenticated ? 'Account' : 'Sign in',
icon: User,
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
},
];
return (