Refine booking flow

This commit is contained in:
Roba Boru
2026-07-02 16:03:57 +03:00
parent 118ba46930
commit f2ee62b95f
16 changed files with 921 additions and 471 deletions

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
import { LogIn, UserPlus, ChevronLeft } from 'lucide-react'; import { LogIn, UserPlus, ChevronLeft } from 'lucide-react';
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
@@ -41,6 +42,23 @@ function Tooltip({ children, content }: { children: React.ReactNode; content: st
export default function AuthCheckPage() { export default function AuthCheckPage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, initialize } = useAuthStore(); const { isAuthenticated, initialize } = useAuthStore();
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const buildResultsUrl = () => {
if (!searchCriteria) return '/booking/results';
const params = new URLSearchParams({
tripType: searchCriteria.tripType,
origin: searchCriteria.originStationId,
destination: searchCriteria.destinationStationId,
date: searchCriteria.departureDate,
adults: searchCriteria.adultCount.toString(),
children: searchCriteria.childCount.toString(),
nationality: searchCriteria.nationality,
});
if (searchCriteria.returnDate) params.set('returnDate', searchCriteria.returnDate);
if (searchCriteria.promoCode) params.set('promoCode', searchCriteria.promoCode);
return `/booking/results?${params}`;
};
useEffect(() => { useEffect(() => {
initialize(); initialize();
@@ -94,7 +112,7 @@ export default function AuthCheckPage() {
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<button <button
onClick={() => router.push('/booking/results')} onClick={() => router.push(buildResultsUrl())}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />

View File

@@ -18,6 +18,7 @@ import {
Wallet Wallet
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import QRCode from 'qrcode.react'; import QRCode from 'qrcode.react';
function BookingDetailContent() { function BookingDetailContent() {
@@ -234,10 +235,10 @@ function BookingDetailContent() {
{/* Origin */} {/* Origin */}
<div className="pb-8"> <div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} {booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} {booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.origin?.name} {booking.schedule?.origin?.name}
@@ -267,10 +268,10 @@ function BookingDetailContent() {
{/* Destination */} {/* Destination */}
<div> <div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} {booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} {booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.destination?.name} {booking.schedule?.destination?.name}
@@ -493,10 +494,10 @@ function BookingDetailContent() {
{/* Origin */} {/* Origin */}
<div className="pb-8"> <div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} {booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} {booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.origin?.name} {booking.schedule?.origin?.name}
@@ -526,10 +527,10 @@ function BookingDetailContent() {
{/* Destination */} {/* Destination */}
<div> <div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} {booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} {booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{booking.schedule?.destination?.name} {booking.schedule?.destination?.name}

View File

@@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react'; import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft, AlertCircle } from 'lucide-react';
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
@@ -17,24 +17,73 @@ function daysInGCMonth(y: number, m: number) {
return new Date(y, m, 0).getDate(); return new Date(y, m, 0).getDate();
} }
const ADULT_MIN_AGE = 6; // adults must be older than 5 years
const CHILD_MAX_AGE = 5; // children must be 5 years old or younger
const ADULT_MAX_AGE_SPAN = 110;
const ADULT_DEFAULT_YEAR = 2000;
// ─── fayda passenger tracking ───────────────────────────────────────────────
// Fayda's OAuth redirect can land back on this route either inside the popup
// window we opened, or — on browsers/mobile contexts that refuse to open a
// popup — as a full-page navigation of this same tab. React state doesn't
// survive that reload, so we track which passenger triggered the verification
// in sessionStorage, which does survive same-tab navigation.
const FAYDA_PENDING_INDEX_KEY = 'edr_fayda_pending_passenger_index';
function getPendingFaydaIndex(): number | null {
if (typeof window === 'undefined') return null;
const raw = window.sessionStorage.getItem(FAYDA_PENDING_INDEX_KEY);
if (raw === null) return null;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function setPendingFaydaIndex(index: number) {
if (typeof window === 'undefined') return;
window.sessionStorage.setItem(FAYDA_PENDING_INDEX_KEY, String(index));
}
function clearPendingFaydaIndex() {
if (typeof window === 'undefined') return;
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_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();
if (g === 'MALE' || g === 'M') return 'Male';
if (g === 'FEMALE' || g === 'F') return 'Female';
return '';
}
function DobPickerModal({ function DobPickerModal({
value, value,
onChange, onChange,
error, error,
passengerType = 'ADULT',
}: { }: {
value: string; value: string;
onChange: (iso: string) => void; onChange: (iso: string) => void;
error?: string; error?: string;
passengerType?: 'ADULT' | 'CHILD';
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [manualMode, setManualMode] = useState(false); const [manualMode, setManualMode] = useState(false);
const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian'); const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian');
const isChild = passengerType === 'CHILD';
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const currentEthYear = gregorianToEthiopian(new Date()).year; const currentEthYear = gregorianToEthiopian(new Date()).year;
// Selectable year bounds, scoped to passenger type so the picker can't produce an invalid age
const minGCYear = isChild ? currentYear - CHILD_MAX_AGE : currentYear - ADULT_MAX_AGE_SPAN;
const maxGCYear = isChild ? currentYear : currentYear - ADULT_MIN_AGE;
const minEthYear = isChild ? currentEthYear - CHILD_MAX_AGE : currentEthYear - ADULT_MAX_AGE_SPAN;
const maxEthYear = isChild ? currentEthYear : currentEthYear - ADULT_MIN_AGE;
// Parse stored ISO value (always Gregorian) // Parse stored ISO value (always Gregorian)
const parsed = value ? value.split('-') : []; const parsed = value ? value.split('-') : [];
const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25; const defaultGCYear = isChild ? maxGCYear : Math.min(Math.max(ADULT_DEFAULT_YEAR, minGCYear), maxGCYear);
const initGCYear = parsed[0] ? parseInt(parsed[0]) : defaultGCYear;
const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1; const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1;
const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1; const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1;
@@ -44,7 +93,7 @@ function DobPickerModal({
const [selGCDay, setSelGCDay] = useState(initGCDay); const [selGCDay, setSelGCDay] = useState(initGCDay);
// Ethiopian drum state — initialise from parsed value if present // Ethiopian drum state — initialise from parsed value if present
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 }; const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: isChild ? maxEthYear : Math.min(Math.max(ADULT_DEFAULT_YEAR - 8, minEthYear), maxEthYear), month: 1, day: 1 };
const [selEthYear, setSelEthYear] = useState(initEth.year); const [selEthYear, setSelEthYear] = useState(initEth.year);
const [selEthMonth, setSelEthMonth] = useState(initEth.month); const [selEthMonth, setSelEthMonth] = useState(initEth.month);
const [selEthDay, setSelEthDay] = useState(initEth.day); const [selEthDay, setSelEthDay] = useState(initEth.day);
@@ -60,8 +109,8 @@ function DobPickerModal({
const gcSafeDay = Math.min(selGCDay, gcMaxDay); const gcSafeDay = Math.min(selGCDay, gcMaxDay);
const ethSafeDay = Math.min(selEthDay, ethMaxDay); const ethSafeDay = Math.min(selEthDay, ethMaxDay);
const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i); const gcYears = Array.from({ length: maxGCYear - minGCYear + 1 }, (_, i) => maxGCYear - i);
const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i); const ethYears = Array.from({ length: maxEthYear - minEthYear + 1 }, (_, i) => maxEthYear - i);
const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1); const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1);
@@ -115,11 +164,11 @@ function DobPickerModal({
const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current; const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current;
const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current; const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current;
const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current; const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => gcYears)).current;
const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current; const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current;
const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current; const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current;
const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current; const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => ethYears)).current;
const confirm = () => { const confirm = () => {
let gregDate: Date; let gregDate: Date;
@@ -137,16 +186,20 @@ function DobPickerModal({
const confirmManual = () => { const confirmManual = () => {
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return; if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < minGCYear || y > maxGCYear) return;
onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`); onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`);
setOpen(false); setOpen(false);
}; };
const manualValid = (() => { const manualValid = (() => {
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m); return d >= 1 && m >= 1 && m <= 12 && y >= minGCYear && y <= maxGCYear && d <= daysInGCMonth(y, m);
})(); })();
const manualRangeMessage = isChild
? `Child's date of birth must fall between ${minGCYear} and ${maxGCYear} (age 5 or younger)`
: `Adult's date of birth must fall between ${minGCYear} and ${maxGCYear} (age older than 5)`;
// Display: always show Gregorian ISO as human-readable, with calendar label // Display: always show Gregorian ISO as human-readable, with calendar label
const displayValue = (() => { const displayValue = (() => {
if (!value) return ''; if (!value) return '';
@@ -276,11 +329,12 @@ function DobPickerModal({
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label> <label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label>
<input type="number" min={currentYear - 110} max={currentYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" /> <input type="number" min={minGCYear} max={maxGCYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
</div> </div>
</div> </div>
<p className="text-xs text-gray-400">Valid years: {minGCYear} {maxGCYear}</p>
{manDay && manMonth && manYear && !manualValid && ( {manDay && manMonth && manYear && !manualValid && (
<p className="text-red-500 text-xs">Please enter a valid Gregorian date</p> <p className="text-red-500 text-xs">{manualRangeMessage}</p>
)} )}
</div> </div>
) : ( ) : (
@@ -453,6 +507,21 @@ function PhoneInput({
); );
} }
// ─── age calculation ─────────────────────────────────────────────────────────
function calculateAge(dateOfBirth: string): number | null {
if (!dateOfBirth) return null;
const dob = new Date(dateOfBirth);
if (isNaN(dob.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
}
// ─── passenger zod schema ────────────────────────────────────────────────────── // ─── passenger zod schema ──────────────────────────────────────────────────────
const passengerSchema = z.object({ const passengerSchema = z.object({
@@ -460,8 +529,6 @@ const passengerSchema = z.object({
dateOfBirth: z.string().min(1, 'Date of birth is required'), dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.string().min(1, 'Gender is required'), gender: z.string().min(1, 'Gender is required'),
nationality: z.string().min(1, 'Nationality is required'), nationality: z.string().min(1, 'Nationality is required'),
phone: z.string(),
email: z.string().optional(),
nationalId: z.string().optional(), nationalId: z.string().optional(),
passportNumber: z.string().optional(), passportNumber: z.string().optional(),
passportCountry: z.string().optional(), passportCountry: z.string().optional(),
@@ -475,16 +542,6 @@ const passengerSchema = z.object({
if (data.gender !== 'Male' && data.gender !== 'Female') { if (data.gender !== 'Male' && data.gender !== 'Female') {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
} }
if (data.email && data.email.trim().length > 0) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
}
}
const phoneError = validatePhone(data.phone, data.nationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
}
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) { if (isNonEthiopian) {
if (!data.passportNumber || data.passportNumber.trim().length === 0) { if (!data.passportNumber || data.passportNumber.trim().length === 0) {
@@ -496,12 +553,41 @@ const passengerSchema = z.object({
} }
}); });
const formSchema = z.object({ function createFormSchema(adultCount: number) {
passengers: z.array(passengerSchema), return z.object({
createAccount: z.boolean(), passengers: z.array(passengerSchema),
}); createAccount: z.boolean(),
contactPhone: z.string(),
contactEmail: z.string(),
}).superRefine((data, ctx) => {
if (!data.contactEmail || data.contactEmail.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Contact email is required', path: ['contactEmail'] });
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.contactEmail)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['contactEmail'] });
}
}
const contactNationality = data.passengers[0]?.nationality || 'ETHIOPIAN';
const phoneError = validatePhone(data.contactPhone, contactNationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['contactPhone'] });
}
type FormData = z.infer<typeof formSchema>; data.passengers.forEach((p, i) => {
const age = calculateAge(p.dateOfBirth);
if (age === null) return;
const isAdult = i < adultCount;
if (isAdult && age <= 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] });
} else if (!isAdult && age > 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] });
}
});
});
}
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
export default function PassengersPage() { export default function PassengersPage() {
const router = useRouter(); const router = useRouter();
@@ -509,17 +595,24 @@ export default function PassengersPage() {
const { user, isAuthenticated, updateUser } = useAuthStore(); const { user, isAuthenticated, updateUser } = useAuthStore();
const isInitialized = useAuthStore((s) => s.isInitialized); const isInitialized = useAuthStore((s) => s.isInitialized);
const [faydaEnabled, setFaydaEnabled] = useState(true); const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({}); const [verificationStatus, setVerificationStatus] = useState<Record<number, 'pending' | 'success' | 'error'>>({});
const [faydaErrors, setFaydaErrors] = useState<Record<number, string>>({});
// The passenger currently mid-verification (popup open / awaiting callback). Only one
// passenger can verify at a time so a stray callback can never be misapplied to the
// wrong passenger's form.
const [verifyingIndex, setVerifyingIndex] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [formInitialized, setFormInitialized] = useState(false); const [formInitialized, setFormInitialized] = useState(false);
const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null); const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null);
const [faydaCompleting, setFaydaCompleting] = useState(false); const [faydaCompleting, setFaydaCompleting] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
const adultCount = searchCriteria?.adultCount || 1;
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({ const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema as any), resolver: zodResolver(createFormSchema(adultCount) as any),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
passengers: Array.from({ length: totalPassengers }, (_, i) => { passengers: Array.from({ length: totalPassengers }, (_, i) => {
@@ -530,8 +623,6 @@ export default function PassengersPage() {
dateOfBirth: stored.dateOfBirth || '', dateOfBirth: stored.dateOfBirth || '',
gender: (stored.gender as any) || undefined, gender: (stored.gender as any) || undefined,
nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN', nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN',
phone: stored.phone || '',
email: stored.email || '',
nationalId: stored.nationalId || '', nationalId: stored.nationalId || '',
passportNumber: stored.passportNumber || '', passportNumber: stored.passportNumber || '',
passportCountry: stored.passportCountry || '', passportCountry: stored.passportCountry || '',
@@ -547,8 +638,6 @@ export default function PassengersPage() {
dateOfBirth: '', dateOfBirth: '',
gender: undefined, gender: undefined,
nationality: searchCriteria?.nationality || 'ETHIOPIAN', nationality: searchCriteria?.nationality || 'ETHIOPIAN',
phone: '',
email: '',
nationalId: '', nationalId: '',
passportNumber: '', passportNumber: '',
passportCountry: '', passportCountry: '',
@@ -556,10 +645,12 @@ export default function PassengersPage() {
passportExpiryDate: '', passportExpiryDate: '',
passportIssuingAuthority: '', passportIssuingAuthority: '',
faydaVerified: false, faydaVerified: false,
formExpanded: false, formExpanded: i >= adultCount,
}; };
}), }),
createAccount: false, createAccount: false,
contactPhone: storedPassengers[0]?.phone || '',
contactEmail: storedPassengers[0]?.email || '',
}, },
}); });
@@ -584,15 +675,24 @@ export default function PassengersPage() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const code = params.get('code'); const code = params.get('code');
const state = params.get('state'); const state = params.get('state');
if (code && state) setFaydaParams({ code, state }); if (code && state) {
setFaydaParams({ code, state });
const pendingIndex = getPendingFaydaIndex();
if (pendingIndex !== null) setVerifyingIndex(pendingIndex);
}
}, []); }, []);
// 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
// inside the verification popup, or, if the browser refused to open a popup, as a full
// navigation of this same tab. Either way, the index of the passenger who started the
// verification was stashed in sessionStorage before redirecting, so it survives the reload.
useEffect(() => { useEffect(() => {
if (!faydaParams || !formInitialized) return; if (!faydaParams || !formInitialized) return;
const complete = async () => { const complete = async () => {
setFaydaCompleting(true); setFaydaCompleting(true);
const targetIndex = getPendingFaydaIndex() ?? 0;
try { try {
const response: any = await apiClient.get( const response: any = await apiClient.get(
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}` `/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
@@ -600,25 +700,50 @@ export default function PassengersPage() {
if (response?.success && response?.data?.verified) { if (response?.success && response?.data?.verified) {
const d = response.data; const d = response.data;
// Convert "1980/12/01" → "1980-12-01" const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
setValue('passengers.0.name', d.fullName || '', { shouldValidate: true }); // A single Fayda identity can't be reused across two different passengers.
if (dob) setValue('passengers.0.dateOfBirth', dob, { shouldValidate: true }); const usedByOther = faydaSub && passengers.some(
if (d.email) setValue('passengers.0.email', d.email, { shouldValidate: true }); (p, i) => i !== targetIndex && (p as any).faydaSub === faydaSub,
if (d.phoneNumber) setValue('passengers.0.phone', d.phoneNumber, { shouldValidate: true }); );
setValue('passengers.0.faydaVerified', true);
setValue('passengers.0.formExpanded', true);
setVerificationStatus((prev) => ({ ...prev, 0: 'success' }));
// Remove code/state from the URL so a refresh doesn't re-trigger if (usedByOther) {
router.replace('/booking/passengers'); setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'This Fayda identity is already linked to another passenger on this booking.' }));
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
} else {
// Convert "1980/12/01" → "1980-12-01"
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
setValue(`passengers.${targetIndex}.name`, d.fullName || '', { shouldValidate: true });
if (dob) setValue(`passengers.${targetIndex}.dateOfBirth`, dob, { shouldValidate: true });
const normalizedGender = normalizeFaydaGender(d.gender);
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
// Contact info is shared across all passengers — only fill it in if nobody has
// entered it yet, so verifying passenger 2 can't clobber passenger 1's contact.
if (d.email && !watch('contactEmail')) setValue('contactEmail', d.email, { shouldValidate: true });
if (d.phoneNumber && !watch('contactPhone')) setValue('contactPhone', d.phoneNumber, { shouldValidate: 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; });
}
} else {
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification could not be completed. Please try again or enter details manually.' }));
} }
// Remove code/state from the URL so a refresh doesn't re-trigger
router.replace('/booking/passengers');
} catch (error) { } catch (error) {
console.error('Failed to complete Fayda verification:', error); console.error('Failed to complete Fayda verification:', error);
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification failed. Please try again or enter details manually.' }));
} finally { } finally {
setFaydaCompleting(false); setFaydaCompleting(false);
setFaydaParams(null); setFaydaParams(null);
setVerifyingIndex(null);
clearPendingFaydaIndex();
} }
}; };
@@ -656,8 +781,8 @@ export default function PassengersPage() {
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); if (passengerData?.phone || user.phone) setValue('contactPhone', passengerData?.phone || user.phone || '');
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); if (passengerData?.email || user.email) setValue('contactEmail', passengerData?.email || user.email || '');
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry); if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
@@ -678,6 +803,16 @@ 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
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
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).
setPendingFaydaIndex(index);
try { try {
const response: any = await apiClient.post('/fayda/verification/start', { const response: any = await apiClient.post('/fayda/verification/start', {
@@ -698,33 +833,67 @@ export default function PassengersPage() {
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes` `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 () => { const checkPopup = setInterval(async () => {
if (popup?.closed) { if (popup.closed) {
clearInterval(checkPopup); clearInterval(checkPopup);
try { try {
const statusResponse: any = await apiClient.get('/fayda/verification/status'); const statusResponse: any = await apiClient.get('/fayda/verification/status');
if (statusResponse.verified) { if (statusResponse.verified) {
setValue(`passengers.${index}.name`, statusResponse.fullName || ''); const faydaSub: string | undefined = statusResponse.sub || statusResponse.faydaSub || statusResponse.fin;
setValue(`passengers.${index}.faydaVerified`, true); const usedByOther = faydaSub && passengers.some(
setValue(`passengers.${index}.formExpanded`, true); (p, i) => i !== index && (p as any).faydaSub === faydaSub,
setVerificationStatus({ ...verificationStatus, [index]: 'success' }); );
if (index === 0 && isAuthenticated) { if (usedByOther) {
updateUser({ setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
fullName: statusResponse.fullName, setFaydaErrors((prev) => ({ ...prev, [index]: 'This Fayda identity is already linked to another passenger on this booking.' }));
faydaVerified: true, } else {
faydaVerifiedAt: statusResponse.verifiedAt, 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) { } catch (error) {
console.error('Failed to get verification status:', error); console.error('Failed to get verification status:', error);
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' }));
} finally {
setVerifyingIndex(null);
clearPendingFaydaIndex();
} }
} }
}, 1000); }, 1000);
} catch (error) { } catch (error) {
console.error('Failed to start Fayda verification:', error); console.error('Failed to start Fayda verification:', error);
alert('Failed to start verification. Please try again.'); setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
setVerifyingIndex(null);
clearPendingFaydaIndex();
} }
}; };
@@ -732,8 +901,15 @@ export default function PassengersPage() {
setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded); setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded);
}; };
const onInvalid = () => {
// Sections still behind the Fayda verify screen stay collapsed here — they only expand
// when the user explicitly clicks "Skip for now" / "Enter details manually".
setSubmitError('Please fix the highlighted errors before continuing.');
};
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
setSaving(true); setSaving(true);
setSubmitError(null);
try { try {
let passengerId = ''; let passengerId = '';
@@ -759,8 +935,8 @@ export default function PassengersPage() {
passportIssueDate: p.passportIssueDate, passportIssueDate: p.passportIssueDate,
passportExpiryDate: p.passportExpiryDate, passportExpiryDate: p.passportExpiryDate,
passportIssuingAuthority: p.passportIssuingAuthority, passportIssuingAuthority: p.passportIssuingAuthority,
phone: p.phone || '', phone: data.contactPhone,
email: p.email || '', email: data.contactEmail,
isPrimaryPassenger: i === 0, isPrimaryPassenger: i === 0,
passengerId: i === 0 && passengerId ? passengerId : undefined, passengerId: i === 0 && passengerId ? passengerId : undefined,
})) }))
@@ -809,7 +985,9 @@ export default function PassengersPage() {
<div className="max-w-lg mx-auto text-center"> <div className="max-w-lg mx-auto text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" /> <Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
<p className="text-gray-600 dark:text-gray-400 mt-4"> <p className="text-gray-600 dark:text-gray-400 mt-4">
{faydaCompleting ? 'Completing Fayda verification...' : 'Loading passenger details...'} {faydaCompleting
? `Completing Fayda verification for Passenger ${(verifyingIndex ?? 0) + 1}...`
: 'Loading passenger details...'}
</p> </p>
</div> </div>
</div> </div>
@@ -823,22 +1001,26 @@ export default function PassengersPage() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1> <h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
{fields.map((field, index) => { {fields.map((field, index) => {
const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN'; const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN';
const isFormExpanded = passengers[index]?.formExpanded; const isFormExpanded = passengers[index]?.formExpanded;
const status = verificationStatus[index]; const status = verificationStatus[index];
const isPrimaryPassenger = index === 0; const isPrimaryPassenger = index === 0;
const isChildPassenger = index >= adultCount;
const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified; const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified;
const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified; const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified;
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified; const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger;
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded; const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded && !isChildPassenger;
const isVerifyingThis = verifyingIndex === index;
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
const faydaError = faydaErrors[index];
return ( return (
<div key={field.id} className="card"> <div key={field.id} className="card">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
Passenger {index + 1} {index === 0 && '(Primary)'} Passenger {index + 1} {index === 0 && '(Primary)'}
{index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'} {isChildPassenger ? ' - Child' : ' - Adult'}
<span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400"> <span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400">
({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'}) ({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'})
</span> </span>
@@ -853,18 +1035,40 @@ export default function PassengersPage() {
</p> </p>
</div> </div>
)} )}
{faydaError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300">{faydaError}</p>
</div>
)}
<button <button
type="button" type="button"
onClick={() => openFaydaVerification(index)} onClick={() => openFaydaVerification(index)}
className="btn-primary flex items-center justify-center gap-2 mx-auto" disabled={isVerifyingThis || isVerifyingOther}
className="btn-primary flex items-center justify-center gap-2 mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
> >
<ExternalLink className="w-5 h-5" /> {isVerifyingThis ? (
{isLoggedInNotVerified ? 'Verify with Fayda' : 'Verify with Fayda'} <>
<Loader2 className="w-5 h-5 animate-spin" />
Waiting for verification...
</>
) : (
<>
<ExternalLink className="w-5 h-5" />
Verify with Fayda
</>
)}
</button> </button>
{isVerifyingOther && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first
</p>
)}
<button <button
type="button" type="button"
onClick={() => toggleForm(index)} onClick={() => toggleForm(index)}
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto" disabled={isVerifyingThis}
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
> >
Skip for now Skip for now
</button> </button>
@@ -889,10 +1093,16 @@ 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 <CheckCircle className="w-4 h-4" /> Verified with Fayda details auto-filled below
</p> </p>
</div> </div>
)} )}
{status === 'error' && faydaError && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg mb-4 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-red-700 dark:text-red-300 text-sm">{faydaError}</p>
</div>
)}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Full Name */} {/* Full Name */}
@@ -915,6 +1125,7 @@ export default function PassengersPage() {
value={passengers[index]?.dateOfBirth || ''} value={passengers[index]?.dateOfBirth || ''}
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'}
/> />
</div> </div>
@@ -944,32 +1155,6 @@ export default function PassengersPage() {
disabled disabled
/> />
</div> </div>
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -995,6 +1180,7 @@ export default function PassengersPage() {
value={passengers[index]?.dateOfBirth || ''} value={passengers[index]?.dateOfBirth || ''}
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'}
/> />
</div> </div>
@@ -1024,32 +1210,6 @@ export default function PassengersPage() {
disabled disabled
/> />
</div> </div>
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[index]?.nationality || 'OTHER'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div> </div>
{/* Passport fields */} {/* Passport fields */}
@@ -1107,6 +1267,40 @@ export default function PassengersPage() {
); );
})} })}
<div className="card">
<h3 className="text-lg font-semibold mb-1 text-gray-900 dark:text-gray-100">Contact Information</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
This phone number and email will be used for booking and ticketing communication for all passengers.
</p>
<div className="grid md:grid-cols-2 gap-4">
{/* Contact Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<PhoneInput
nationality={passengers[0]?.nationality || searchCriteria?.nationality || 'ETHIOPIAN'}
storedValue={watch('contactPhone') || ''}
onInterimChange={(v) => setValue('contactPhone', v)}
onNormalized={(v) => setValue('contactPhone', v, { shouldValidate: true })}
error={errors.contactPhone?.message}
/>
</div>
{/* Contact Email */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
<input
type="email"
{...register('contactEmail')}
className={`input-field ${errors.contactEmail ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.contactEmail && (
<p className="text-red-500 text-xs mt-1">{errors.contactEmail.message}</p>
)}
</div>
</div>
</div>
{!isAuthenticated && ( {!isAuthenticated && (
<div className="card"> <div className="card">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@@ -1116,6 +1310,13 @@ export default function PassengersPage() {
</div> </div>
)} )}
{submitError && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300">{submitError}</p>
</div>
)}
<div className="flex gap-4"> <div className="flex gap-4">
<button type="button" onClick={() => { <button type="button" onClick={() => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -1132,8 +1333,10 @@ export default function PassengersPage() {
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
Back Back
</button> </button>
<button type="submit" className="btn-primary flex-1" disabled={saving}> <button type="submit" className="btn-primary flex-1 flex items-center justify-center gap-2" disabled={saving}>
{saving ? 'Saving...' : 'Continue to seat selection'} {saving
? <Loader2 className="w-4 h-4 animate-spin" />
: 'Continue to seat selection'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -8,6 +8,8 @@ import { apiClient } from "@/lib/api-client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { format } from "date-fns"; import { format } from "date-fns";
import { formatTime, getTimePeriod } from '@/utils/format';
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { import {
CreditCard, CreditCard,
Smartphone, Smartphone,
@@ -65,21 +67,21 @@ export default function PaymentPage() {
enabled: !!selectedMethod && !!bookingId, enabled: !!selectedMethod && !!bookingId,
}); });
// Fallback: estimate from local store while API hasn't responded yet // Fallback: estimate from local store while API hasn't responded yet.
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( // Uses the same first-child-free calculation as the review page so the
(sum) => sum + (outboundSchedule.baseFareAdult || 0), // breakdown shown here matches what the passenger already saw there.
0, const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
) : 0; return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce( const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
(sum) => sum + (inboundSchedule.baseFareAdult || 0), return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
0, }, 0) : 0;
) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce( const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
(sum) => sum + (selectedSchedule?.baseFareAdult || 0), const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
0, return sum + calculatePassengerFare(passengers, i, farePerPassenger);
); }, 0);
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
const totalAmount = bookingAmountData != null const totalAmount = bookingAmountData != null
@@ -203,10 +205,12 @@ export default function PaymentPage() {
<div className="flex-1 flex flex-col pl-2"> <div className="flex-1 flex flex-col pl-2">
<div className="pb-5"> <div className="pb-5">
<div className="text-lg font-bold text-gray-900 dark:text-white"> <div className="text-lg font-bold text-gray-900 dark:text-white">
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'HH:mm') : '--:--'} {schedule?.departureTime ? formatTime(schedule.departureTime) : '--:--'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'EEE, MMM d') : ''} {schedule?.departureTime
? `${format(new Date(schedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.departureTime)}`
: ''}
</div> </div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div> <div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div>
</div> </div>
@@ -216,10 +220,12 @@ export default function PaymentPage() {
</div> </div>
<div> <div>
<div className="text-lg font-bold text-gray-900 dark:text-white"> <div className="text-lg font-bold text-gray-900 dark:text-white">
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'HH:mm') : '--:--'} {schedule?.arrivalTime ? formatTime(schedule.arrivalTime) : '--:--'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'EEE, MMM d') : ''} {schedule?.arrivalTime
? `${format(new Date(schedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.arrivalTime)}`
: ''}
</div> </div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div> <div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div>
</div> </div>
@@ -240,7 +246,9 @@ export default function PaymentPage() {
<div className="card space-y-4"> <div className="card space-y-4">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800"> <h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Order summary Order summary
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">Ref: {pnr}</span> <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
Ref: <span className="font-bold text-gray-900 dark:text-gray-100">{pnr}</span>
</span>
</h2> </h2>
{isRoundTrip ? ( {isRoundTrip ? (
@@ -254,14 +262,58 @@ export default function PaymentPage() {
<JourneyLeg schedule={selectedSchedule} label="Your journey" /> <JourneyLeg schedule={selectedSchedule} label="Your journey" />
)} )}
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-1.5"> {/* Fare breakdown — same first-child-free logic as the review page */}
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400"> <div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
<span>Passengers</span> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
<span className="font-medium text-gray-900 dark:text-gray-100"> {passengers.map((p, i) => {
{passengers.length} passenger{passengers.length !== 1 ? 's' : ''} const outFare = outboundSchedule?.baseFareAdult || 0;
</span> const inFare = inboundSchedule?.baseFareAdult || 0;
</div> const onewayFare = selectedSchedule?.baseFareAdult || 0;
<div className="flex justify-between items-center pt-1">
const outboundFare = calculatePassengerFare(passengers, i, outFare);
const inboundFare = calculatePassengerFare(passengers, i, inFare);
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
const isChildPassenger = isChild(p);
const isFreeChild = isChildPassenger && isFirstChild(passengers, i);
return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<div className="flex justify-between mb-0.5">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{p.name || `Passenger ${i + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${
isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
</span>
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatFare(passengerTotal, displayCurrency)}
</span>
</div>
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(outboundFare, displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(inboundFare, displayCurrency)}</span>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span> <span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5"> <span className="text-xl font-bold text-primary flex items-center gap-1.5">
{loadingAmount && ( {loadingAmount && (
@@ -311,6 +363,22 @@ export default function PaymentPage() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1> <h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
{/* Reservation confirmation banner */}
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-800 dark:text-green-300">
Your booking is successfully reserved
</p>
<p className="text-sm text-green-700 dark:text-green-400 mt-0.5">
Booking Reference: <span className="font-bold">{pnr}</span>
</p>
<p className="text-xs text-green-700/80 dark:text-green-400/80 mt-1">
Please complete the payment below to confirm your booking. Your seats are held temporarily until payment is completed.
</p>
</div>
</div>
{/* Payment Processing Overlay */} {/* Payment Processing Overlay */}
{isProcessing && ( {isProcessing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">

View File

@@ -3,7 +3,7 @@
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store'; import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react'; import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function TelebirrFailureContent() { function TelebirrFailureContent() {
const router = useRouter(); const router = useRouter();
@@ -30,11 +30,6 @@ function TelebirrFailureContent() {
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>} {merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>} {trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
<div className="flex flex-col gap-3 mt-4"> <div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')} <button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full flex items-center justify-center gap-2"> className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />

View File

@@ -3,7 +3,7 @@
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store'; import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react'; import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function WaafiFailureContent() { function WaafiFailureContent() {
const router = useRouter(); const router = useRouter();
@@ -33,11 +33,6 @@ function WaafiFailureContent() {
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p> <p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
)} )}
<div className="flex flex-col gap-3 mt-4"> <div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')} <button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full flex items-center justify-center gap-2"> className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />

View File

@@ -7,6 +7,7 @@ import { useBookingStore } from '@/lib/booking-store';
import { Schedule } from '@/types'; import { Schedule } from '@/types';
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react'; import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
export default function ResultsPage() { export default function ResultsPage() {
@@ -19,6 +20,10 @@ export default function ResultsPage() {
); );
const [classModal, setClassModal] = useState<Schedule | null>(null); const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => {
const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState();
return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound';
});
const searchCriteria = useBookingStore((s) => s.searchCriteria); const searchCriteria = useBookingStore((s) => s.searchCriteria);
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
@@ -28,7 +33,7 @@ export default function ResultsPage() {
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '', destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
date: searchParams.get('date') || searchCriteria?.departureDate || '', date: searchParams.get('date') || searchCriteria?.departureDate || '',
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate, returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', journeyType: (searchParams.get('tripType') ?? searchCriteria?.tripType ?? 'ONE_WAY') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1, adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0, childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN', nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
@@ -111,6 +116,8 @@ export default function ResultsPage() {
return response; return response;
}, },
enabled: !!searchData.originStationId && !!searchData.destinationStationId, enabled: !!searchData.originStationId && !!searchData.destinationStationId,
staleTime: 0,
gcTime: 0,
}); });
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
@@ -188,18 +195,13 @@ export default function ResultsPage() {
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
}; };
// For round trip, store outbound and wait for inbound selection // For round trip, store outbound and advance to inbound step
if (isRoundTrip && isOutbound) { if (isRoundTrip && isOutbound) {
setOutboundScheduleData(scheduleData); setOutboundScheduleData(scheduleData);
setOutboundSchedule(scheduleData); setOutboundSchedule(scheduleData);
setClassModal(null); setClassModal(null);
// Scroll to inbound section setRoundTripStep('inbound');
setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' });
const inboundSection = document.getElementById('inbound-section');
if (inboundSection) {
inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
return; return;
} }
@@ -256,10 +258,10 @@ export default function ResultsPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} {schedule.departureAt ? formatTime(schedule.departureAt) : '--:--'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} {schedule.departureAt ? `${format(new Date(schedule.departureAt), 'MMM d')} · ${getTimePeriod(schedule.departureAt)}` : ''}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div> <div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
</div> </div>
@@ -283,10 +285,10 @@ export default function ResultsPage() {
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} {schedule.arrivalAt ? formatTime(schedule.arrivalAt) : '--:--'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span> <span>{schedule.arrivalAt ? `${format(new Date(schedule.arrivalAt), 'MMM d')} · ${getTimePeriod(schedule.arrivalAt)}` : ''}</span>
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>} {isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div> <div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
@@ -699,6 +701,33 @@ export default function ResultsPage() {
Modify search Modify search
</button> </button>
<h1 className="section-title">Available schedules</h1> <h1 className="section-title">Available schedules</h1>
{isRoundTrip && (
<div className="flex items-center gap-3 mt-4">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
roundTripStep === 'inbound'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-primary text-white shadow-md shadow-primary/30'
}`}>
{roundTripStep === 'inbound'
? <Check className="w-3.5 h-3.5" />
: <span className="text-xs leading-none">1</span>}
<span>Outbound</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-0.5 bg-gray-300 dark:bg-gray-600" />
<ArrowRight className="w-3 h-3 text-gray-400" />
<div className="w-4 h-0.5 bg-gray-300 dark:bg-gray-600" />
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
roundTripStep === 'inbound'
? 'bg-primary text-white shadow-md shadow-primary/30'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}`}>
<span className="text-xs leading-none">2</span>
<span>Return</span>
</div>
</div>
)}
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3"> <div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
@@ -718,39 +747,74 @@ export default function ResultsPage() {
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
{outboundSchedules.length > 0 && ( {isRoundTrip ? (
<div> roundTripStep === 'outbound' ? (
{isRoundTrip && ( <div>
<div className="mb-4"> <div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2"> <h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary" /> <ArrowRight className="w-5 h-5 text-primary" />
Outbound Journey Select Outbound Journey
</h2> </h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''} {searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
</p> </p>
</div> </div>
)} <div className="space-y-4">
<div className="space-y-4"> {outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))} </div>
</div> </div>
</div> ) : (
)} <div>
{outboundScheduleData && (
{isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && ( <div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 flex items-center justify-between">
<div id="inbound-section"> <div className="flex items-center gap-3">
<div className="mb-4"> <div className="w-8 h-8 bg-green-100 dark:bg-green-900/40 rounded-full flex items-center justify-center flex-shrink-0">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2"> <Check className="w-4 h-4 text-green-600 dark:text-green-400" />
<ArrowRight className="w-5 h-5 text-primary rotate-180" /> </div>
Return Journey <div>
</h2> <p className="text-sm font-semibold text-green-900 dark:text-green-200">Outbound journey selected</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''} {outboundScheduleData.origin} {outboundScheduleData.destination}
</p> {outboundScheduleData.selectedSeatClassName ? ` · ${outboundScheduleData.selectedSeatClassName}` : ''}
</div> </p>
<div className="space-y-4"> </div>
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))} </div>
<button
onClick={() => {
setRoundTripStep('outbound');
const prevId = outboundScheduleData?.id;
setOutboundScheduleData(null);
if (prevId) {
setSelectedCoachTypes(prev => {
const next = { ...prev };
delete next[prevId];
return next;
});
}
}}
className="text-xs font-semibold text-primary hover:underline flex-shrink-0 ml-4"
>
Change
</button>
</div>
)}
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
Select Return Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
</p>
</div>
<div className="space-y-4" id="inbound-section">
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
</div>
</div> </div>
)
) : (
<div className="space-y-4">
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
</div> </div>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ import { useAuthStore } from '@/lib/auth-store';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
@@ -93,6 +94,14 @@ export default function ReviewPage() {
return suffix ? `${base}${suffix}` : base; return suffix ? `${base}${suffix}` : base;
}; };
// The seat class/category is chosen once per leg (coach type selected on /booking/seats),
// so every seat on that leg shares it — no need to look it up per-seat.
const formatSeatClass = (schedule: any): string => {
const raw = schedule?.selectedSeatClassName || schedule?.seatClassName || schedule?.selectedSeatClass;
if (!raw) return 'Standard';
return String(raw).replace(/_/g, ' ');
};
useEffect(() => { useEffect(() => {
const fetchSeatDetails = async () => { const fetchSeatDetails = async () => {
try { try {
@@ -525,10 +534,12 @@ export default function ReviewPage() {
{/* Origin */} {/* Origin */}
<div className="pb-8"> <div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} {outboundSchedule.departureTime ? formatTime(outboundSchedule.departureTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} {outboundSchedule.departureTime
? `${format(new Date(outboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.departureTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule.origin} {outboundSchedule.origin}
@@ -556,10 +567,12 @@ export default function ReviewPage() {
{/* Destination */} {/* Destination */}
<div> <div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} {outboundSchedule.arrivalTime ? formatTime(outboundSchedule.arrivalTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} {outboundSchedule.arrivalTime
? `${format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.arrivalTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule.destination} {outboundSchedule.destination}
@@ -598,10 +611,12 @@ export default function ReviewPage() {
{/* Origin */} {/* Origin */}
<div className="pb-8"> <div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} {inboundSchedule.departureTime ? formatTime(inboundSchedule.departureTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} {inboundSchedule.departureTime
? `${format(new Date(inboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.departureTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule.origin} {inboundSchedule.origin}
@@ -629,10 +644,12 @@ export default function ReviewPage() {
{/* Destination */} {/* Destination */}
<div> <div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} {inboundSchedule.arrivalTime ? formatTime(inboundSchedule.arrivalTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} {inboundSchedule.arrivalTime
? `${format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.arrivalTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule.destination} {inboundSchedule.destination}
@@ -671,10 +688,12 @@ export default function ReviewPage() {
{/* Origin */} {/* Origin */}
<div className="pb-8"> <div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} {selectedSchedule.departureTime ? formatTime(selectedSchedule.departureTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} {selectedSchedule.departureTime
? `${format(new Date(selectedSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.departureTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule.origin} {selectedSchedule.origin}
@@ -702,10 +721,12 @@ export default function ReviewPage() {
{/* Destination */} {/* Destination */}
<div> <div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} {selectedSchedule.arrivalTime ? formatTime(selectedSchedule.arrivalTime) : '--:--'}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} {selectedSchedule.arrivalTime
? `${format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.arrivalTime)}`
: 'N/A'}
</div> </div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2"> <div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule.destination} {selectedSchedule.destination}
@@ -736,18 +757,27 @@ export default function ReviewPage() {
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100"> <p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
</p> </p>
{(p as any).outboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
)}
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p> <p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100"> <p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
</p> </p>
{(p as any).inboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
)}
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-right"> <div className="text-right">
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p> <p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p> <p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
{p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,14 +4,13 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { useBookingStore } from "@/lib/booking-store"; import { useBookingStore } from "@/lib/booking-store";
import { Station } from "@/types"; import { Station } from "@/types";
import { import {
MapPin, MapPin,
ArrowRight,
ArrowLeftRight, ArrowLeftRight,
Plus, Plus,
Minus, Minus,
@@ -23,7 +22,6 @@ import {
X, X,
ChevronLeft, ChevronLeft,
Clock, Clock,
Zap,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import ModernDatePicker from "@/components/ModernDatePicker"; import ModernDatePicker from "@/components/ModernDatePicker";
@@ -79,12 +77,6 @@ const searchSchema = z
type SearchForm = z.infer<typeof searchSchema>; type SearchForm = z.infer<typeof searchSchema>;
const POPULAR_ROUTES = [
{ from: "Sebeta", to: "Nagad", duration: "12h", icon: "🌆" },
{ from: "Sebeta", to: "Diredawa", duration: "8h", icon: "🏔️" },
{ from: "Diredawa", to: "Nagad", duration: "4h", icon: "🌊" },
];
// ─── Station Modal ──────────────────────────────────────────────────────────── // ─── Station Modal ────────────────────────────────────────────────────────────
function StationModal({ function StationModal({
stations, stations,
@@ -530,6 +522,8 @@ export default function SearchPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const clearBooking = useBookingStore((s) => s.clearBooking);
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAuthStore(); const { user, isAuthenticated } = useAuthStore();
const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [passengerModalOpen, setPassengerModalOpen] = useState(false);
@@ -701,7 +695,10 @@ export default function SearchPage() {
const onSubmit = (data: SearchForm) => { const onSubmit = (data: SearchForm) => {
setHasInteracted(true); setHasInteracted(true);
// Clear previous booking selections and search cache before starting a new search
clearBooking();
setSearchCriteria(data); setSearchCriteria(data);
queryClient.removeQueries({ queryKey: ["search"] });
if (data.originStationId) saveRecent(data.originStationId); if (data.originStationId) saveRecent(data.originStationId);
if (data.destinationStationId) saveRecent(data.destinationStationId); if (data.destinationStationId) saveRecent(data.destinationStationId);
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -723,20 +720,6 @@ export default function SearchPage() {
const originStation = getStationById(originId); const originStation = getStationById(originId);
const destStation = getStationById(destId); const destStation = getStationById(destId);
const handlePopularRoute = (fromName: string, toName: string) => {
const origin = stations.find((s) =>
s.name.toLowerCase().includes(fromName.toLowerCase()),
);
const dest = stations.find((s) =>
s.name.toLowerCase().includes(toName.toLowerCase()),
);
if (origin && dest) {
setValue("originStationId", origin.id);
setValue("destinationStationId", dest.id);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
return ( return (
<div className="bg-gray-50 dark:bg-gray-950"> <div className="bg-gray-50 dark:bg-gray-950">
{/* Passenger modal (mobile) */} {/* Passenger modal (mobile) */}
@@ -790,9 +773,15 @@ export default function SearchPage() {
)} )}
{/* ── 90vh hero with banner image ── */} {/* ── 90vh hero with banner image ── */}
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
upward from its bottom-anchored position — give the hero extra height there so the
widget's top edge doesn't creep up into the sticky header. */}
<section <section
className="relative" className={`relative ${
style={{ height: "90vh", minHeight: "560px" }} tripType === "ROUND_TRIP"
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
: "h-[94vh] min-h-[560px]"
}`}
> >
{/* Background image with zoom - fully isolated */} {/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
@@ -956,7 +945,7 @@ export default function SearchPage() {
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date Date
</label> </label>
<div className="relative z-30"> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
departureDate departureDate
@@ -980,7 +969,7 @@ export default function SearchPage() {
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date Return Date
</label> </label>
<div className="relative z-20"> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
returnDate returnDate
@@ -1059,7 +1048,11 @@ export default function SearchPage() {
clearErrors("originStationId"); clearErrors("originStationId");
clearErrors("destinationStationId"); clearErrors("destinationStationId");
}} }}
error={hasInteracted ? errors.originStationId?.message : undefined} error={
hasInteracted
? errors.originStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView} onOpen={scrollWidgetIntoView}
/> />
{hasInteracted && errors.originStationId && ( {hasInteracted && errors.originStationId && (
@@ -1094,7 +1087,11 @@ export default function SearchPage() {
if (s.id) saveRecent(s.id); if (s.id) saveRecent(s.id);
clearErrors("destinationStationId"); clearErrors("destinationStationId");
}} }}
error={hasInteracted ? errors.destinationStationId?.message : undefined} error={
hasInteracted
? errors.destinationStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView} onOpen={scrollWidgetIntoView}
/> />
{hasInteracted && errors.destinationStationId && ( {hasInteracted && errors.destinationStationId && (
@@ -1110,7 +1107,7 @@ export default function SearchPage() {
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date Date
</label> </label>
<div className="relative z-30"> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
departureDate departureDate
@@ -1191,7 +1188,11 @@ export default function SearchPage() {
clearErrors("originStationId"); clearErrors("originStationId");
clearErrors("destinationStationId"); clearErrors("destinationStationId");
}} }}
error={hasInteracted ? errors.originStationId?.message : undefined} error={
hasInteracted
? errors.originStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView} onOpen={scrollWidgetIntoView}
/> />
{hasInteracted && errors.originStationId && ( {hasInteracted && errors.originStationId && (
@@ -1226,7 +1227,11 @@ export default function SearchPage() {
if (s.id) saveRecent(s.id); if (s.id) saveRecent(s.id);
clearErrors("destinationStationId"); clearErrors("destinationStationId");
}} }}
error={hasInteracted ? errors.destinationStationId?.message : undefined} error={
hasInteracted
? errors.destinationStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView} onOpen={scrollWidgetIntoView}
/> />
{hasInteracted && errors.destinationStationId && ( {hasInteracted && errors.destinationStationId && (
@@ -1242,7 +1247,7 @@ export default function SearchPage() {
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Departure Departure
</label> </label>
<div className="relative z-30"> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
departureDate departureDate
@@ -1272,7 +1277,7 @@ export default function SearchPage() {
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Return
</label> </label>
<div className="relative z-20"> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
returnDate returnDate
@@ -1497,41 +1502,6 @@ export default function SearchPage() {
</div> </div>
</section> </section>
{/* Popular Routes — below hero */}
<div className="bg-gray-50 dark:bg-gray-950 py-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 mb-4">
<Zap className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">
Popular Routes
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{POPULAR_ROUTES.map((route, idx) => (
<button
key={idx}
type="button"
onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95"
>
<div className="text-xl mb-2">{route.icon}</div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<span>{route.from}</span>
<ArrowRight className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span>{route.to}</span>
</div>
<div className="text-xs text-gray-400 mt-1 flex items-center gap-1">
<Clock className="w-3 h-3" />
{route.duration} journey
</div>
</button>
))}
</div>
</div>
</div>
</div>
<style jsx>{` <style jsx>{`
@keyframes slide-up { @keyframes slide-up {
from { from {

View File

@@ -21,7 +21,7 @@ const buildSeatLabel = (seat: any): string => {
return suffix ? `${base}${suffix}` : base; return suffix ? `${base}${suffix}` : base;
}; };
const BedCard = memo(({ bed, isSelected, onToggle }: any) => { const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
const bedPosition = bed.bedPosition || ""; const bedPosition = bed.bedPosition || "";
const bedType = const bedType =
@@ -30,21 +30,28 @@ const BedCard = memo(({ bed, isSelected, onToggle }: any) => {
: bedPosition === "middle" : bedPosition === "middle"
? "Middle" ? "Middle"
: "Lower"; : "Lower";
const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
return ( return (
<button <button
onClick={() => onToggle(bed.id)} onClick={() => onToggle(bed.id)}
disabled={bed.status !== "AVAILABLE"} disabled={isDisabled}
className={`relative flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-lg transition-all ${ className={`relative flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-lg transition-all ${
isSelected isSelected
? "bg-blue-50 border-2 border-blue-500 dark:bg-blue-900/20 dark:border-blue-400" ? "bg-blue-50 border-2 border-blue-500 dark:bg-blue-900/20 dark:border-blue-400"
: bed.status === "AVAILABLE" : isAssignedToOther
? "bg-green-50 border border-green-300 hover:bg-green-100 dark:bg-green-900/20 dark:border-green-700" ? "bg-purple-50 border border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
: bed.status === "BOOKED" : bed.status === "AVAILABLE"
? "bg-red-50 border border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700" ? "bg-green-50 border border-green-300 hover:bg-green-100 dark:bg-green-900/20 dark:border-green-700"
: "bg-gray-100 border border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700" : bed.status === "BOOKED"
? "bg-red-50 border border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
: "bg-gray-100 border border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
}`} }`}
title={`Bed ${seatLabel} - ${bedType} - ${bed.status}`} title={
isAssignedToOther
? `Bed ${seatLabel} - already assigned to another passenger`
: `Bed ${seatLabel} - ${bedType} - ${bed.status}`
}
> >
<Image src="/bed.png" alt="bed" width={32} height={32} className="object-contain" /> <Image src="/bed.png" alt="bed" width={32} height={32} className="object-contain" />
<div className="text-xs font-bold text-gray-900 dark:text-white"> <div className="text-xs font-bold text-gray-900 dark:text-white">
@@ -63,6 +70,7 @@ const SeatButton = memo(
({ ({
seat, seat,
isSelected, isSelected,
isAssignedToOther,
onToggle, onToggle,
isBedCoach, isBedCoach,
bedLabel, bedLabel,
@@ -71,22 +79,29 @@ const SeatButton = memo(
const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
const bedWidth = "w-24"; const bedWidth = "w-24";
const width = isBedCoach ? bedWidth : "w-10"; const width = isBedCoach ? bedWidth : "w-10";
const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<button <button
onClick={() => onToggle(seat.id)} onClick={() => onToggle(seat.id)}
disabled={seat.status !== "AVAILABLE"} disabled={isDisabled}
className={`${width} h-11 rounded flex items-center justify-center transition-all ${ className={`${width} h-11 rounded flex items-center justify-center transition-all ${
isSelected isSelected
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105" ? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
: seat.status === "AVAILABLE" : isAssignedToOther
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md" ? "bg-purple-400 text-white cursor-not-allowed opacity-75"
: seat.status === "HELD" : seat.status === "AVAILABLE"
? "bg-yellow-500 text-white cursor-not-allowed opacity-75" ? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
: "bg-gray-500 text-white cursor-not-allowed opacity-60" : seat.status === "HELD"
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
}`} }`}
title={`Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`} title={
isAssignedToOther
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
}
style={ style={
isBedCoach isBedCoach
? seat.row % 2 === 1 ? seat.row % 2 === 1
@@ -122,7 +137,10 @@ export default function SeatsPage() {
searchCriteria, searchCriteria,
bookingId, bookingId,
} = useBookingStore(); } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState<string[]>([]); // Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null); const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [currentJourneyType, setCurrentJourneyType] = useState< const [currentJourneyType, setCurrentJourneyType] = useState<
"outbound" | "inbound" "outbound" | "inbound"
@@ -344,71 +362,87 @@ export default function SeatsPage() {
return seats; return seats;
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
const handleSeatClick = useCallback( // Seats already claimed by any passenger in this journey leg
(seatId: string) => { const assignedSeatIds = useMemo(
setSelectedSeats((prev) => { () => new Set(Object.values(passengerSeatMap)),
if (prev.length < passengers.length) { [passengerSeatMap],
return [...prev, seatId];
} else {
return [seatId];
}
});
},
[passengers.length],
); );
const handleContinue = async () => { // The furthest passenger a user is allowed to jump to — cannot skip ahead of the
if (isRoundTrip && currentJourneyType === "outbound") { // first passenger who still needs a seat.
if (selectedSeats.length > 0) { const firstUnassignedIndex = useMemo(
try { () => passengers.findIndex((_, i) => !passengerSeatMap[i]),
await holdMutation.mutateAsync(selectedSeats); [passengers, passengerSeatMap],
const updatedPassengers = passengers.map((p, i) => { );
const seatData = validSeats?.find( const maxSelectableIndex =
(s: any) => s.id === selectedSeats[i], firstUnassignedIndex === -1 ? passengers.length - 1 : firstUnassignedIndex;
);
return {
...p,
outboundSeatId: selectedSeats[i],
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
};
});
setPassengers(updatedPassengers);
} catch (error: any) {
setModalState({
isOpen: true,
title: "Seat Hold Failed",
message:
error?.response?.data?.message ||
"Failed to hold seats. Please try again.",
type: "error",
});
return;
}
}
setCurrentJourneyType("inbound");
setSelectedSeats([]);
setSelectedCoach(null);
return;
}
if (selectedSeats.length > 0) { const isSeatSelected = useCallback(
(seatId: string) => passengerSeatMap[activePassengerIndex] === seatId,
[passengerSeatMap, activePassengerIndex],
);
const isSeatAssignedToOther = useCallback(
(seatId: string) =>
Object.entries(passengerSeatMap).some(
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
),
[passengerSeatMap, activePassengerIndex],
);
const handleSelectPassenger = useCallback(
(index: number) => {
if (index > maxSelectableIndex) return; // no skipping ahead of unassigned passengers
setActivePassengerIndex(index);
},
[maxSelectableIndex],
);
const handleSeatClick = useCallback(
(seatId: string) => {
// Seat already claimed by a different passenger — never allow duplicate assignment
const takenByOther = Object.entries(passengerSeatMap).some(
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
);
if (takenByOther) return;
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
const next = { ...passengerSeatMap };
if (isDeselecting) {
delete next[activePassengerIndex];
} else {
next[activePassengerIndex] = seatId;
}
setPassengerSeatMap(next);
if (!isDeselecting) {
// Move on to the next passenger who still needs a seat — one passenger at a time
const nextUnassigned = passengers.findIndex(
(_, i) => i !== activePassengerIndex && !next[i],
);
if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned);
}
},
[passengerSeatMap, activePassengerIndex, passengers],
);
const allSeatsAssigned =
passengers.length > 0 &&
passengers.every((_, i) => !!passengerSeatMap[i]);
const handleContinue = async () => {
if (!allSeatsAssigned) return;
const seatIds = passengers.map((_, i) => passengerSeatMap[i]);
if (isRoundTrip && currentJourneyType === "outbound") {
try { try {
await holdMutation.mutateAsync(selectedSeats); await holdMutation.mutateAsync(seatIds);
const updatedPassengers = passengers.map((p, i) => { const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find( const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
(s: any) => s.id === selectedSeats[i],
);
if (isRoundTrip && currentJourneyType === "inbound") {
return {
...p,
inboundSeatId: selectedSeats[i],
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
};
}
return { return {
...p, ...p,
seatId: selectedSeats[i], outboundSeatId: seatIds[i],
seatNumber: seatData ? buildSeatLabel(seatData) : '', outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
}; };
}); });
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
@@ -423,42 +457,32 @@ export default function SeatsPage() {
}); });
return; return;
} }
} setCurrentJourneyType("inbound");
router.push("/booking/review"); setPassengerSeatMap({});
}; setActivePassengerIndex(0);
setSelectedCoach(null);
const handleAutoAssign = async () => {
const availableSeats =
validSeats?.filter((s: any) => s.status === "AVAILABLE") || [];
if (availableSeats.length < passengers.length) {
setModalState({
isOpen: true,
title: "Not Enough Seats",
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${passengers.length} seat(s). Please select another coach.`,
type: "warning",
});
return; return;
} }
const autoSelectedSeats = availableSeats
.slice(0, passengers.length)
.map((s: any) => s.id);
setSelectedSeats(autoSelectedSeats);
try { try {
await holdMutation.mutateAsync(autoSelectedSeats); await holdMutation.mutateAsync(seatIds);
const updatedPassengers = passengers.map((p, i) => { const updatedPassengers = passengers.map((p, i) => {
const seatData = availableSeats[i]; const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
if (isRoundTrip && currentJourneyType === "inbound") {
return {
...p,
inboundSeatId: seatIds[i],
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
};
}
return { return {
...p, ...p,
seatId: autoSelectedSeats[i], seatId: seatIds[i],
seatNumber: seatData ? buildSeatLabel(seatData) : '', seatNumber: seatData ? buildSeatLabel(seatData) : '',
}; };
}); });
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
router.push("/booking/review");
} catch (error: any) { } catch (error: any) {
console.error("Failed to hold seats:", error);
setModalState({ setModalState({
isOpen: true, isOpen: true,
title: "Seat Hold Failed", title: "Seat Hold Failed",
@@ -467,7 +491,38 @@ export default function SeatsPage() {
"Failed to hold seats. Please try again.", "Failed to hold seats. Please try again.",
type: "error", type: "error",
}); });
return;
} }
router.push("/booking/review");
};
const handleAutoAssign = () => {
const unassignedIndices = passengers
.map((_, i) => i)
.filter((i) => !passengerSeatMap[i]);
if (unassignedIndices.length === 0) return;
const availableSeats = (
validSeats?.filter((s: any) => s.status === "AVAILABLE") || []
).filter((s: any) => !assignedSeatIds.has(s.id));
if (availableSeats.length < unassignedIndices.length) {
setModalState({
isOpen: true,
title: "Not Enough Seats",
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
type: "warning",
});
return;
}
const next = { ...passengerSeatMap };
unassignedIndices.forEach((passengerIndex, offset) => {
next[passengerIndex] = availableSeats[offset].id;
});
setPassengerSeatMap(next);
setActivePassengerIndex(passengers.length - 1);
}; };
const handleBackToPassengers = () => { const handleBackToPassengers = () => {
@@ -494,8 +549,9 @@ export default function SeatsPage() {
]); ]);
useEffect(() => { useEffect(() => {
if (bookingId && selectedSeats.length > 0) { const heldSeatIds = Object.values(passengerSeatMap);
bookSeatsMutation.mutate(selectedSeats); if (bookingId && heldSeatIds.length > 0) {
bookSeatsMutation.mutate(heldSeatIds);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]); }, [bookingId]);
@@ -636,7 +692,8 @@ export default function SeatsPage() {
<div key={bed.id} className="relative"> <div key={bed.id} className="relative">
<BedCard <BedCard
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="U" bedLabel="U"
/> />
@@ -661,7 +718,8 @@ export default function SeatsPage() {
<div key={bed.id} className="relative"> <div key={bed.id} className="relative">
<BedCard <BedCard
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="L" bedLabel="L"
/> />
@@ -695,7 +753,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="L" bedLabel="L"
/> />
@@ -713,7 +772,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="L" bedLabel="L"
/> />
@@ -739,7 +799,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="M" bedLabel="M"
/> />
@@ -757,7 +818,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="M" bedLabel="M"
/> />
@@ -783,7 +845,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="U" bedLabel="U"
/> />
@@ -801,7 +864,8 @@ export default function SeatsPage() {
<BedCard <BedCard
key={bed.id} key={bed.id}
bed={bed} bed={bed}
isSelected={selectedSeats.includes(bed.id)} isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
bedLabel="U" bedLabel="U"
/> />
@@ -880,7 +944,8 @@ export default function SeatsPage() {
<SeatButton <SeatButton
key={seat.id} key={seat.id}
seat={seat} seat={seat}
isSelected={selectedSeats.includes(seat.id)} isSelected={isSeatSelected(seat.id)}
isAssignedToOther={isSeatAssignedToOther(seat.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
isBedCoach={true} isBedCoach={true}
bedLabel={getBedLabel(seat.bedPosition)} bedLabel={getBedLabel(seat.bedPosition)}
@@ -992,7 +1057,8 @@ export default function SeatsPage() {
<SeatButton <SeatButton
key={seat.id} key={seat.id}
seat={seat} seat={seat}
isSelected={selectedSeats.includes(seat.id)} isSelected={isSeatSelected(seat.id)}
isAssignedToOther={isSeatAssignedToOther(seat.id)}
onToggle={handleSeatClick} onToggle={handleSeatClick}
isBedCoach={false} isBedCoach={false}
bedLabel="" bedLabel=""
@@ -1065,7 +1131,8 @@ export default function SeatsPage() {
); );
} }
const allSelected = selectedSeats.length === passengers.length; const assignedCount = passengers.filter((_, i) => !!passengerSeatMap[i]).length;
const activePassenger = passengers[activePassengerIndex];
const isBedCoach = const isBedCoach =
selectedCoachData?.isBedCoach === true || selectedCoachData?.isBedCoach === true ||
selectedCoachData?.rooms?.length > 0 || selectedCoachData?.rooms?.length > 0 ||
@@ -1081,18 +1148,18 @@ export default function SeatsPage() {
</h3> </h3>
<span <span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
allSelected allSeatsAssigned
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" : "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
}`} }`}
> >
{selectedSeats.length}/{passengers.length} selected {assignedCount}/{passengers.length} selected
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{allSelected {allSeatsAssigned
? "All seats selected — ready to continue" ? "All seats selected — ready to continue"
: `Select ${passengers.length - selectedSeats.length} more seat(s)`} : `Selecting seat for ${activePassenger?.name || `Passenger ${activePassengerIndex + 1}`} (${activePassengerIndex + 1} of ${passengers.length})`}
</p> </p>
{/* Progress bar */} {/* Progress bar */}
@@ -1100,75 +1167,88 @@ export default function SeatsPage() {
<div <div
className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300" className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300"
style={{ style={{
width: `${(selectedSeats.length / passengers.length) * 100}%`, width: `${(assignedCount / passengers.length) * 100}%`,
}} }}
/> />
</div> </div>
<div className="space-y-2 mb-5"> <div className="space-y-2 mb-5">
{passengers.map((p, i) => { {passengers.map((p, i) => {
const assignedSeat = selectedSeats[i] const assignedSeatId = passengerSeatMap[i];
? validSeats?.find((s: any) => s.id === selectedSeats[i]) const assignedSeat = assignedSeatId
? validSeats?.find((s: any) => s.id === assignedSeatId)
: null; : null;
const seatLabel = assignedSeat const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
? assignedSeat.number || const isActive = i === activePassengerIndex;
assignedSeat.label || const isClickable = i <= maxSelectableIndex;
assignedSeat.seatNumber ||
"—"
: "—";
const bedLabel = assignedSeat
? getBedLabel(assignedSeat.bedPosition)
: "";
return ( return (
<div <button
key={i} key={i}
className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0" type="button"
onClick={() => handleSelectPassenger(i)}
disabled={!isClickable}
className={`w-full flex items-center justify-between py-2 px-2 rounded-lg border text-left transition-all ${
isActive
? "border-[rgb(20,113,76)] bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10"
: "border-transparent"
} ${
isClickable
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
: "cursor-not-allowed opacity-50"
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<div <div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${ className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
assignedSeat assignedSeat
? "bg-[rgb(20,113,76)] text-white" ? "bg-[rgb(20,113,76)] text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-500" : isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`} }`}
> >
{i + 1} {i + 1}
</div> </div>
<span className="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[120px]"> <div className="min-w-0">
{p.name} <span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[120px]">
</span> {p.name}
</span>
{isActive && !assignedSeat && (
<span className="text-[10px] font-semibold text-[rgb(20,113,76)] uppercase tracking-wide">
Now selecting
</span>
)}
</div>
</div> </div>
<span <span
className={`text-sm font-semibold ${ className={`text-sm font-semibold flex-shrink-0 ${
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400" assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`} }`}
> >
{assignedSeat ? `${seatLabel}${bedLabel}` : "Not selected"} {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
</span> </span>
</div> </button>
); );
})} })}
</div> </div>
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={selectedSeats.length === 0 || holdMutation.isPending} disabled={!allSeatsAssigned || holdMutation.isPending}
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg" className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
> >
{holdMutation.isPending {holdMutation.isPending
? "Holding seats..." ? "Holding seats..."
: isRoundTrip && currentJourneyType === "outbound" : isRoundTrip && currentJourneyType === "outbound"
? "Continue to Return Seats" ? "Continue to Return Seats"
: allSelected : "Continue"}
? "Continue"
: "Continue with partial selection"}
</button> </button>
<button <button
onClick={handleAutoAssign} onClick={handleAutoAssign}
disabled={holdMutation.isPending} disabled={holdMutation.isPending || allSeatsAssigned}
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all" className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
> >
Auto-assign seats Auto Assign Seats
</button> </button>
</> </>
); );
@@ -1183,21 +1263,17 @@ export default function SeatsPage() {
type={modalState.type} type={modalState.type}
/> />
{/* Mobile summary bottom-sheet */} {/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
{selectedSeats.length > 0 && ( <div
<> className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5 max-h-[70vh] overflow-y-auto"
<div style={{
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5" animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
style={{ }}
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)", >
}} <div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
> <SummaryContent />
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" /> </div>
<SummaryContent /> <style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
</div>
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
</>
)}
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */} {/* Header */}
@@ -1211,15 +1287,22 @@ export default function SeatsPage() {
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
Back Back
</button> </button>
<h1 className="text-base font-bold text-gray-900 dark:text-white"> <div className="text-center">
{isRoundTrip <h1 className="text-base font-bold text-gray-900 dark:text-white">
? currentJourneyType === "outbound" {isRoundTrip
? "Select Outbound Seats" ? currentJourneyType === "outbound"
: "Select Return Seats" ? "Select Outbound Seats"
: "Select Seats"} : "Select Return Seats"
</h1> : "Select Seats"}
</h1>
{!allSeatsAssigned && (
<p className="text-xs text-gray-500 dark:text-gray-400">
Now selecting: {activePassenger?.name || `Passenger ${activePassengerIndex + 1}`}
</p>
)}
</div>
<div className="text-sm font-semibold text-[rgb(20,113,76)]"> <div className="text-sm font-semibold text-[rgb(20,113,76)]">
{selectedSeats.length}/{passengers.length} {assignedCount}/{passengers.length}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -104,7 +104,7 @@ function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
try { try {
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
} catch { return iso; } } catch { return iso; }
} }

View File

@@ -1,12 +1,27 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import SearchPage from '@/app/booking/search/page'; import SearchPage from '@/app/booking/search/page';
import PackagesSection from '@/components/PackagesSection'; import PackagesSection from '@/components/PackagesSection';
export default function Home() { export default async function Home() {
const queryClient = new QueryClient();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
await queryClient.prefetchQuery({
queryKey: ['stations'],
queryFn: async () => {
const res = await fetch(`${apiUrl}/stations`, { next: { revalidate: 3600 } });
const json = await res.json();
return json?.data ?? json;
},
});
return ( return (
<Suspense> <HydrationBoundary state={dehydrate(queryClient)}>
<SearchPage /> <Suspense>
<PackagesSection /> <SearchPage />
</Suspense> <PackagesSection />
</Suspense>
</HydrationBoundary>
); );
} }

View File

@@ -31,7 +31,7 @@ export default function AppHeader() {
return ( return (
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm"> <header className="sticky top-0 z-[60] bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">

View File

@@ -146,7 +146,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
</div> </div>
{/* Date */} {/* Date */}
<div className="space-y-2 relative z-30 md:col-span-1"> <div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
<ModernDatePicker <ModernDatePicker
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined} value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}

View File

@@ -119,7 +119,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null
const dep = new Date(schedule.departureAt); const dep = new Date(schedule.departureAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33); doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38); doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
@@ -143,7 +143,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null
const arr = new Date(schedule.arrivalAt); const arr = new Date(schedule.arrivalAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33); doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38); doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);

View File

@@ -13,9 +13,17 @@ export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyy
}; };
export const formatDateTime = (date: string | Date): string => { export const formatDateTime = (date: string | Date): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm'); return format(new Date(date), 'MMM dd, yyyy h:mm a');
}; };
export const formatTime = (date: string | Date): string => { export const formatTime = (date: string | Date): string => {
return format(new Date(date), 'HH:mm'); return format(new Date(date), 'h:mm a');
};
export const getTimePeriod = (date: string | Date): string => {
const hour = new Date(date).getHours();
if (hour >= 5 && hour < 12) return 'Morning';
if (hour >= 12 && hour < 17) return 'Afternoon';
if (hour >= 17 && hour < 21) return 'Evening';
return 'Night';
}; };