mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Update booking result page for round trip
This commit is contained in:
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
@@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => {
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
||||
const { selectedCurrency, setPaymentIntent, updateStatus } =
|
||||
usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ['paymentMethods'],
|
||||
queryFn: async () => {
|
||||
@@ -38,10 +41,21 @@ export default function PaymentPage() {
|
||||
});
|
||||
|
||||
// Calculate total amount
|
||||
const baseFare = passengers.reduce(
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
) : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (inboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
) : 0;
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce(
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = baseFare;
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
@@ -207,47 +221,231 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
<h2 className="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
|
||||
Order summary
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.origin} → {selectedSchedule?.destination}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.trainNumber}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-6">
|
||||
{isRoundTrip ? (
|
||||
<>
|
||||
{/* Outbound Journey */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Outbound Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||
{outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{outboundSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {outboundSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Return Journey */}
|
||||
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Return Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-medium">
|
||||
{inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{inboundSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {inboundSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* One-Way Journey */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||
{selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700" />
|
||||
|
||||
{/* Origin */}
|
||||
<div className="relative pb-16">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule?.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="relative pb-16 -mt-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{selectedSchedule?.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {selectedSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="relative -mt-8">
|
||||
<div className="absolute left-[-1.625rem] top-0 w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900" />
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule?.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Passengers and Total */}
|
||||
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm mb-3">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Class
|
||||
Passengers
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
|
||||
{passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Passengers
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{passengers.length} passenger
|
||||
{passengers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||
Total amount
|
||||
</span>
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
<span className="text-2xl font-bold text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ function TelebirrSuccessContent() {
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
|
||||
// Telebirr callback query params
|
||||
const merchantOrderId = searchParams.get('merchantOrderId') || '';
|
||||
const orderid = searchParams.get('orderid') || '';
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
|
||||
|
||||
@@ -25,7 +25,7 @@ function TelebirrSuccessContent() {
|
||||
try {
|
||||
if (bookingIdQp) {
|
||||
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
|
||||
paymentReference: merchantOrderId || trxRef,
|
||||
paymentReference: orderid || trxRef,
|
||||
paymentMethod: 'TELEBIRR',
|
||||
});
|
||||
}
|
||||
@@ -59,7 +59,7 @@ function TelebirrSuccessContent() {
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
|
||||
{merchantOrderId && <p className="text-xs text-gray-400">Order ID: {merchantOrderId}</p>}
|
||||
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
|
||||
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation…</p>
|
||||
</>
|
||||
|
||||
@@ -5,16 +5,16 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Schedule } from '@/types';
|
||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react';
|
||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ResultsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||
const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
|
||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||
const [outboundSelected, setOutboundSelected] = useState(false);
|
||||
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(null);
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||
|
||||
@@ -114,18 +114,21 @@ export default function ResultsPage() {
|
||||
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
|
||||
|
||||
// Handle both response formats:
|
||||
// 1. One-way: response is array of schedules
|
||||
// 1. One-way: response can be array of schedules OR object with journeyType and outbound
|
||||
// 2. Round-trip: response has journeyType, outbound, inbound properties
|
||||
let outboundSchedules: Schedule[] = [];
|
||||
let inboundSchedules: Schedule[] = [];
|
||||
|
||||
if (results) {
|
||||
if (isRoundTrip && results.journeyType === 'ROUND_TRIP') {
|
||||
if (results.journeyType === 'ROUND_TRIP') {
|
||||
// Round trip response format
|
||||
outboundSchedules = results.outbound || [];
|
||||
inboundSchedules = results.inbound || [];
|
||||
} else if (results.journeyType === 'ONE_WAY' && results.outbound) {
|
||||
// One-way response format with outbound array
|
||||
outboundSchedules = results.outbound || [];
|
||||
} else if (Array.isArray(results)) {
|
||||
// One-way response format (array of schedules)
|
||||
// One-way response format (direct array of schedules)
|
||||
outboundSchedules = results;
|
||||
} else if (results.data && Array.isArray(results.data)) {
|
||||
// Fallback: wrapped in data property
|
||||
@@ -139,11 +142,8 @@ export default function ResultsPage() {
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||
if (isOutbound && isRoundTrip) {
|
||||
setOutboundSelected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
@@ -184,13 +184,28 @@ export default function ResultsPage() {
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
if (isRoundTrip && isOutbound) {
|
||||
setOutboundSelected(true);
|
||||
setOutboundScheduleData(scheduleData);
|
||||
setOutboundSchedule(scheduleData);
|
||||
setClassModal(null);
|
||||
// Scroll to inbound section
|
||||
setTimeout(() => {
|
||||
const inboundSection = document.getElementById('inbound-section');
|
||||
if (inboundSection) {
|
||||
inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// For round trip inbound or one-way, proceed to next step
|
||||
setSelectedSchedule(scheduleData);
|
||||
// For round trip inbound, proceed with both schedules
|
||||
if (isRoundTrip && !isOutbound) {
|
||||
setInboundSchedule(scheduleData);
|
||||
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||
} else {
|
||||
// For one-way
|
||||
setSelectedSchedule(scheduleData);
|
||||
}
|
||||
|
||||
router.push('/booking/auth-check');
|
||||
};
|
||||
|
||||
@@ -289,10 +304,138 @@ export default function ResultsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Searching for trains...</p>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Progress Header */}
|
||||
<div className="mb-8">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Searching for trains...
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Finding the best options for your journey
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{
|
||||
animation: 'progressBar 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Cards */}
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card animate-pulse">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
{/* Train info skeleton */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-24"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time and route skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
>
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12 mt-2"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right space-y-3">
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-32 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-16 mx-auto lg:ml-auto"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
<div
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-full"
|
||||
style={{ animation: 'shimmer 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -381,7 +524,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<button
|
||||
key={fareClass.seatClassName}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||
disabled={!isAvailable}
|
||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
isSelected
|
||||
@@ -425,7 +568,7 @@ export default function ResultsPage() {
|
||||
disabled={!selectedClass}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return Flight' : 'Continue'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedClass && (
|
||||
@@ -494,8 +637,8 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
|
||||
<div>
|
||||
{isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && (
|
||||
<div id="inbound-section">
|
||||
<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" />
|
||||
|
||||
@@ -51,11 +51,13 @@ function getPassengerIdFromToken(token: string): string | null {
|
||||
|
||||
export default function ReviewPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore();
|
||||
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore();
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const [timeLeft, setTimeLeft] = useState<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
|
||||
@@ -79,22 +81,57 @@ export default function ReviewPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSeatDetails = async () => {
|
||||
if (!selectedSchedule?.id) return;
|
||||
|
||||
try {
|
||||
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
const details: Record<string, string> = {};
|
||||
passengers.forEach(p => {
|
||||
if (p.seatId) {
|
||||
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
||||
if (seat) {
|
||||
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
|
||||
// Fetch outbound seat details
|
||||
if (isRoundTrip && outboundSchedule?.id) {
|
||||
const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
|
||||
const outboundCoaches = outboundSeatMap?.coaches || [];
|
||||
const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if ((p as any).outboundSeatId) {
|
||||
const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
|
||||
if (seat) {
|
||||
details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch inbound seat details
|
||||
if (isRoundTrip && inboundSchedule?.id) {
|
||||
const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
|
||||
const inboundCoaches = inboundSeatMap?.coaches || [];
|
||||
const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if ((p as any).inboundSeatId) {
|
||||
const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
|
||||
if (seat) {
|
||||
details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch one-way seat details
|
||||
if (!isRoundTrip && selectedSchedule?.id) {
|
||||
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if (p.seatId) {
|
||||
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
||||
if (seat) {
|
||||
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSeatDetails(details);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch seat details:', error);
|
||||
@@ -102,7 +139,7 @@ export default function ReviewPage() {
|
||||
};
|
||||
|
||||
fetchSeatDetails();
|
||||
}, [selectedSchedule?.id, passengers]);
|
||||
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
@@ -155,6 +192,8 @@ export default function ReviewPage() {
|
||||
console.log('Search criteria:', searchCriteria);
|
||||
console.log('Seat hold:', seatHold);
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Outbound schedule:', outboundSchedule);
|
||||
console.log('Inbound schedule:', inboundSchedule);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
if (!seatHold?.holdId) {
|
||||
@@ -171,12 +210,15 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class ID
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
let returnSeatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
console.log('Seat classes:', seatClasses);
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
returnSeatClassId = seatClasses[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
@@ -229,18 +271,20 @@ export default function ReviewPage() {
|
||||
throw new Error('Passenger ID not found in authentication token. Please log in again.');
|
||||
}
|
||||
|
||||
// Build booking request for authenticated users
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
passengerId: passengerId,
|
||||
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
passengerId: passengerId,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
@@ -251,19 +295,34 @@ export default function ReviewPage() {
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Add round trip specific fields
|
||||
if (isRoundTrip && inboundSchedule) {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
// Add promo code if exists
|
||||
if (searchCriteria.promoCode) {
|
||||
bookingData.promoCode = searchCriteria.promoCode;
|
||||
}
|
||||
} else {
|
||||
// For guests: send full passenger details array
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
@@ -279,6 +338,20 @@ export default function ReviewPage() {
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
|
||||
// Add round trip specific fields
|
||||
if (isRoundTrip && inboundSchedule) {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
// Add promo code if exists
|
||||
if (searchCriteria.promoCode) {
|
||||
bookingData.promoCode = searchCriteria.promoCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
@@ -294,26 +367,49 @@ export default function ReviewPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing data');
|
||||
router.push('/booking/search');
|
||||
if (isRoundTrip) {
|
||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing round trip data');
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing data');
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Base fare adult:', selectedSchedule.baseFareAdult);
|
||||
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
|
||||
console.log('Selected schedule:', displaySchedule);
|
||||
console.log('Base fare adult:', displaySchedule?.baseFareAdult);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
const baseFare = passengers.reduce((sum, p, i) => {
|
||||
const farePerPassenger = selectedSchedule.baseFareAdult ||
|
||||
(selectedSchedule as any).fareAdult ||
|
||||
(selectedSchedule as any).price ||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (outboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (inboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult ||
|
||||
(selectedSchedule as any)?.fareAdult ||
|
||||
(selectedSchedule as any)?.price ||
|
||||
0;
|
||||
|
||||
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
|
||||
@@ -340,51 +436,259 @@ export default function ReviewPage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.trainNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.origin} → {selectedSchedule.destination}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Departure</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'}
|
||||
{/* Outbound Trip Details */}
|
||||
{isRoundTrip && outboundSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Outbound Journey</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
|
||||
{outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Arrival</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.duration}</span>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{outboundSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {outboundSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbound Trip Details */}
|
||||
{isRoundTrip && inboundSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Return Journey</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-semibold">
|
||||
{inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{inboundSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {inboundSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One-Way Trip Details */}
|
||||
{!isRoundTrip && selectedSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
|
||||
{selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
<div className="flex">
|
||||
{/* Left column: Timeline with dots and line */}
|
||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||
{/* Origin dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||
{/* Vertical line */}
|
||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||
{/* Destination dot */}
|
||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right column: Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.origin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Info */}
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{selectedSchedule.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="font-medium">Train {selectedSchedule.trainNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.destination}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Passengers</h2>
|
||||
<div className="space-y-3">
|
||||
{passengers.map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<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>
|
||||
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isRoundTrip ? (
|
||||
<div className="grid grid-cols-2 gap-3 mt-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">Outbound Seat</p>
|
||||
<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>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -393,10 +697,23 @@ export default function ReviewPage() {
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
{isRoundTrip ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ SeatButton.displayName = 'SeatButton';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
|
||||
@@ -59,11 +59,12 @@ export default function SeatsPage() {
|
||||
});
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
||||
|
||||
const { data: seatMapData, isLoading, error } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule?.id],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
|
||||
enabled: !!selectedSchedule?.id,
|
||||
queryKey: ['seatmap', currentSchedule?.id, currentJourneyType],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${currentSchedule?.id}`),
|
||||
enabled: !!currentSchedule?.id,
|
||||
});
|
||||
|
||||
const holdMutation = useMutation({
|
||||
@@ -79,7 +80,7 @@ export default function SeatsPage() {
|
||||
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
|
||||
|
||||
return apiClient.post(`/seats/hold`, {
|
||||
scheduleId: selectedSchedule?.id,
|
||||
scheduleId: currentSchedule?.id,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
passengers: passengersForHold,
|
||||
@@ -108,20 +109,20 @@ export default function SeatsPage() {
|
||||
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
||||
|
||||
const filteredCoaches = useMemo(() => {
|
||||
if (!selectedSchedule?.selectedSeatClass) {
|
||||
if (!currentSchedule?.selectedSeatClass) {
|
||||
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
|
||||
}
|
||||
|
||||
let filtered = coaches.filter((c: any) => {
|
||||
const seatClasses = c.seatClasses || [c.seatClass] || [];
|
||||
return seatClasses.some((seatClassName: string) =>
|
||||
seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase()
|
||||
seatClassName === currentSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase()
|
||||
);
|
||||
});
|
||||
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
}, [coaches, currentSchedule?.selectedSeatClass]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredCoaches.length > 0 && !selectedCoach) {
|
||||
@@ -144,15 +145,15 @@ export default function SeatsPage() {
|
||||
let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||
|
||||
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(currentSchedule.selectedSeatClass);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return seats;
|
||||
}, [allSeats, selectedCoachData, selectedSchedule?.selectedSeatClass]);
|
||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||
|
||||
const handleSeatClick = useCallback((seatId: string) => {
|
||||
setSelectedSeats(prev => {
|
||||
@@ -166,7 +167,6 @@ export default function SeatsPage() {
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isRoundTrip && currentJourneyType === 'outbound') {
|
||||
// Save outbound seats and show inbound
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
@@ -174,8 +174,8 @@ export default function SeatsPage() {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
outboundSeatId: selectedSeats[i],
|
||||
outboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
@@ -195,12 +195,18 @@ export default function SeatsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Final continue (one-way or round-trip inbound)
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
if (isRoundTrip && currentJourneyType === 'inbound') {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: selectedSeats[i],
|
||||
inboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
@@ -264,10 +270,16 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
if (isRoundTrip) {
|
||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
} else {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}
|
||||
}, [selectedSchedule, passengers.length, router]);
|
||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && selectedSeats.length > 0) {
|
||||
@@ -595,8 +607,8 @@ export default function SeatsPage() {
|
||||
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
||||
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||
let fSeats = coachSeats;
|
||||
if (isBed && selectedSchedule?.selectedSeatClass) {
|
||||
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||
if (isBed && currentSchedule?.selectedSeatClass) {
|
||||
const bedPos = getBedPosition(currentSchedule.selectedSeatClass);
|
||||
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||
}
|
||||
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
|
||||
|
||||
@@ -108,13 +108,22 @@
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressBar {
|
||||
0% { width: 0%; }
|
||||
50% { width: 60%; }
|
||||
100% { width: 90%; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
export default function AppHeader() {
|
||||
const pathname = usePathname();
|
||||
@@ -12,47 +13,50 @@ export default function AppHeader() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setIsDark(isDarkMode);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDarkMode = html.classList.contains('dark');
|
||||
const isDarkMode = html.classList.contains("dark");
|
||||
if (isDarkMode) {
|
||||
html.classList.remove('dark');
|
||||
html.classList.remove("dark");
|
||||
setIsDark(false);
|
||||
localStorage.setItem('theme', 'light');
|
||||
localStorage.setItem("theme", "light");
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
html.classList.add("dark");
|
||||
setIsDark(true);
|
||||
localStorage.setItem('theme', 'dark');
|
||||
localStorage.setItem("theme", "dark");
|
||||
}
|
||||
};
|
||||
|
||||
const isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
|
||||
|
||||
const isLandingPage = [
|
||||
"/",
|
||||
"/services",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/help",
|
||||
].includes(pathname);
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">
|
||||
Ethio-Djibouti Railway
|
||||
</h1>
|
||||
<p className="text-xs text-gray-100">
|
||||
Book your train journey with us
|
||||
</p>
|
||||
</div>
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-14 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu - only show for landing pages */}
|
||||
@@ -103,7 +107,7 @@ export default function AppHeader() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
title={isDark ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
@@ -117,7 +121,11 @@ export default function AppHeader() {
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
{isOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +165,7 @@ export default function AppHeader() {
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<Link
|
||||
href="/help"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
|
||||
@@ -14,14 +14,17 @@ import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1),
|
||||
destinationStationId: z.string().min(1),
|
||||
departureDate: z.string().min(1),
|
||||
originStationId: z.string().min(1, 'Please select a departure station'),
|
||||
destinationStationId: z.string().min(1, 'Please select an arrival station'),
|
||||
departureDate: z.string().min(1, 'Please select a departure date'),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
adultCount: z.number().min(1, 'At least 1 adult is required').max(9, 'Maximum 9 adults allowed'),
|
||||
childCount: z.number().min(0, 'Cannot be negative').max(9, 'Maximum 9 children allowed'),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
}).refine((data) => data.originStationId !== data.destinationStationId, {
|
||||
}).refine((data) => {
|
||||
if (!data.originStationId || !data.destinationStationId) return true;
|
||||
return data.originStationId !== data.destinationStationId;
|
||||
}, {
|
||||
message: 'Origin and destination must be different',
|
||||
path: ['destinationStationId'],
|
||||
});
|
||||
@@ -43,10 +46,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||
});
|
||||
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
||||
// @ts-ignore - ZodEffects type compatibility issue
|
||||
resolver: zodResolver(searchSchema),
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: {
|
||||
tripType: 'ONE_WAY',
|
||||
originStationId: '',
|
||||
destinationStationId: '',
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
nationality: 'ETHIOPIAN',
|
||||
@@ -89,8 +97,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
||||
<select
|
||||
{...register('originStationId')}
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
{...register('originStationId', {
|
||||
onChange: (e) => {
|
||||
if (e.target.value) clearErrors('originStationId');
|
||||
}
|
||||
})}
|
||||
className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
|
||||
errors.originStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select departure</option>
|
||||
@@ -100,7 +114,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
</select>
|
||||
</div>
|
||||
{errors.originStationId && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.originStationId.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.originStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
||||
<select
|
||||
{...register('destinationStationId')}
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
{...register('destinationStationId', {
|
||||
onChange: (e) => {
|
||||
if (e.target.value) clearErrors('destinationStationId');
|
||||
}
|
||||
})}
|
||||
className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
|
||||
errors.destinationStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select arrival</option>
|
||||
@@ -121,7 +141,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
</select>
|
||||
</div>
|
||||
{errors.destinationStationId && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.destinationStationId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -135,12 +155,13 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
setValue('departureDate', `${year}-${month}-${day}`);
|
||||
clearErrors('departureDate');
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.departureDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
|
||||
{/* Row 3: Search Button */}
|
||||
<div>
|
||||
{/* Debug info - remove after testing */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Validation Errors:</p>
|
||||
<ul className="text-xs text-red-700 dark:text-red-300 list-disc list-inside">
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<li key={key}>{key}: {value?.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl"
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface SeatHold {
|
||||
interface BookingState {
|
||||
searchCriteria: SearchCriteria | null;
|
||||
selectedSchedule: SelectedSchedule | null;
|
||||
outboundSchedule: SelectedSchedule | null;
|
||||
inboundSchedule: SelectedSchedule | null;
|
||||
passengers: PassengerDetail[];
|
||||
seatHold: SeatHold | null;
|
||||
bookingId: string | null;
|
||||
@@ -66,6 +68,8 @@ interface BookingState {
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
setOutboundSchedule: (schedule: SelectedSchedule) => void;
|
||||
setInboundSchedule: (schedule: SelectedSchedule) => void;
|
||||
setPassengers: (passengers: PassengerDetail[]) => void;
|
||||
setSeatHold: (hold: SeatHold | null) => void;
|
||||
setBookingId: (id: string) => void;
|
||||
@@ -80,6 +84,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
(set) => (({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
outboundSchedule: null,
|
||||
inboundSchedule: null,
|
||||
passengers: [],
|
||||
seatHold: null,
|
||||
bookingId: null,
|
||||
@@ -90,6 +96,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
|
||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
||||
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
||||
setPassengers: (passengers) => set({ passengers }),
|
||||
setSeatHold: (hold) => set({ seatHold: hold }),
|
||||
setBookingId: (id) => set({ bookingId: id }),
|
||||
@@ -100,6 +108,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
clearBooking: () => set({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
outboundSchedule: null,
|
||||
inboundSchedule: null,
|
||||
passengers: [],
|
||||
seatHold: null,
|
||||
bookingId: null,
|
||||
|
||||
Reference in New Issue
Block a user