Merge pull request #219 from Tria-plc/alpha

Update seat map and sms payload
This commit is contained in:
Eyob T.
2026-06-18 22:14:56 +03:00
committed by GitHub
8 changed files with 433 additions and 204 deletions

View File

@@ -34,7 +34,7 @@ export class SingleMessageDto {
})
@IsString()
@IsNotEmpty()
sms: string;
message: string;
}
export class BulkMessagesDto {

View File

@@ -21,7 +21,7 @@ export class NotificationsService {
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}

View File

@@ -500,11 +500,12 @@ export class SearchService {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string> }
{ coachType: any; classNames: Set<string>; coachId: string }
>();
for (const assignment of schedule.coachAssignments) {
@@ -515,6 +516,7 @@ export class SearchService {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
coachId: assignment.coach.id,
});
}
@@ -523,7 +525,7 @@ export class SearchService {
}
const result = [];
for (const [, { coachType, classNames }] of coachTypeMap) {
for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
@@ -537,6 +539,7 @@ export class SearchService {
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
coachId,
classes,
});
}

View File

@@ -281,7 +281,7 @@ export class TicketsService {
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
@@ -333,7 +333,7 @@ export class TicketsService {
if (!validLegs.includes(resolvedLeg)) {
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
@@ -435,7 +435,7 @@ export class TicketsService {
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLeg && offlineLeg) {
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (existingLogs.some(l => l.leg === offlineLeg)) {
results.duplicate++;
continue;

View File

@@ -5,7 +5,7 @@ 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, Check, X, MapPin, Gift, Train } 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 { useState, useEffect } from 'react';
@@ -13,7 +13,7 @@ export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
const [selectedCoachTypes, setSelectedCoachTypes] = useState<Record<string, { id: string; code: string; name: string }>>({});
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);
@@ -142,27 +142,22 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
const handleSelectClass = (scheduleId: string, seatClass: string) => {
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } }));
};
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
const selectedCoachType = selectedCoachTypes[scheduleId];
if (!selectedClass) {
alert('Please select a seat class before continuing');
if (!selectedCoachType) {
alert('Please select a coach type before continuing');
return;
}
const selectedClassFare = schedule.faresByClass?.find(
(f: any) => f.seatClassName === selectedClass
);
if (!selectedClassFare) {
alert('Unable to find fare for selected class');
return;
}
// Find the coach type to get pricing info
const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id);
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -176,10 +171,13 @@ export default function ResultsPage() {
departureTime: schedule.departureAt || schedule.departureTime || '',
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
duration: durationStr,
baseFareAdult: selectedClassFare.baseFareMinor,
baseFareChild: selectedClassFare.baseFareMinor,
selectedSeatClass: selectedClass,
selectedSeatClassName: selectedClass,
baseFareAdult: minFare,
baseFareChild: minFare,
selectedSeatClass: selectedCoachType.name,
selectedSeatClassName: selectedCoachType.name,
selectedCoachId: selectedCoachType.id,
selectedCoachTypeCode: selectedCoachType.code,
selectedCoachTypeName: selectedCoachType.name,
};
// For round trip, store outbound and wait for inbound selection
@@ -211,10 +209,16 @@ export default function ResultsPage() {
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
: null;
const selectedCoachType = selectedCoachTypes[scheduleId];
// Calculate lowest fare from coach types
let lowestFare = null;
if (schedule.coachTypes?.length) {
const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0);
lowestFare = allFares.length ? Math.min(...allFares) : null;
} else if (schedule.faresByClass?.length) {
lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0));
}
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
@@ -284,16 +288,16 @@ export default function ResultsPage() {
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
{selectedClass && (
{selectedCoachType && (
<p className="text-xs text-primary font-semibold mb-2">
{selectedClass.replace(/_/g, ' ')} selected
{selectedCoachType.name} selected
</p>
)}
<button
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{selectedClass ? 'Change class' : 'Select class'}
{selectedCoachType ? 'Change' : 'Select'}
</button>
</div>
</div>
@@ -488,95 +492,177 @@ export default function ResultsPage() {
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedClass = selectedClasses[scheduleId];
const selectedCoachType = selectedCoachTypes[scheduleId];
const isOutbound = (classModal as any).isOutbound;
const coachTypes = classModal.coachTypes || [];
const getCoachIcon = (typeName: string) => {
const lower = typeName.toLowerCase();
if (lower.includes('soft') || lower.includes('vip')) return Star;
if (lower.includes('bed')) return Bed;
return Armchair;
};
return (
<>
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
<Train className="w-3 h-3" />
{classModal.trainNumber} · {classModal.origin?.name} {classModal.destination?.name}
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
<Train className="w-3.5 h-3.5" />
<span className="font-medium">{classModal.trainNumber}</span>
<span className="text-gray-400">·</span>
<span>{classModal.origin?.name} {classModal.destination?.name}</span>
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{classModal.faresByClass.map((fareClass: any) => {
const isSelected = selectedClass === fareClass.seatClassName;
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
const isAvailable = availableSeats > 0;
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
<div className="flex-1 overflow-y-auto px-6 py-4">
{coachTypes.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachId;
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const CoachIcon = getCoachIcon(coachType.coachTypeName);
return (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
disabled={!isAvailable}
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
key={coachType.coachId}
onClick={() => handleSelectCoachType(scheduleId, coachType.coachId, coachType.coachTypeCode, coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
}`}
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
>
{isSelected && (
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3.5 h-3.5 text-white" />
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<Check className="w-4 h-4 text-white" strokeWidth={3} />
</div>
)}
<p className="font-semibold text-gray-900 dark:text-white pr-8">
{fareClass.seatClassName.replace(/_/g, ' ')}
</p>
<p className="text-xl font-bold text-primary dark:text-white mt-2">
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
</p>
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
<p className={`text-xs mt-2 font-medium ${
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
}`}>
{isAvailable
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
: 'Sold out'}
</p>
<div className="flex flex-col">
<div className="flex items-start gap-4 pr-2">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
isSelected
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
}`}>
<CoachIcon className={`w-6 h-6 transition-colors ${
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
{coachType.coachTypeName}
</h3>
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
<span className={`text-2xl font-bold tracking-tight ${
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
}`}>
{(minPrice / 100).toFixed(2)}
</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">ETB</span>
</div>
</div>
</div>
</div>
{coachType.classes.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Class Options
</p>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachType.classes.length} available
</span>
</div>
<div className="space-y-2.5">
{coachType.classes.map((cls: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
>
<div className="flex items-center gap-2.5">
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{(cls.baseFareMinor / 100).toFixed(2)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
ETB
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</button>
);
})}
</div>
) : (
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<Train className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
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 Flight' : 'Continue'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedClass && (
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
)}
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
<div className="w-full max-w-md">
<button
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
disabled={!selectedCoachType}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
>
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedCoachType && (
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
Select a coach type to continue
</p>
)}
</div>
</div>
</div>
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
<style>{`
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
`}</style>
</>
);
})()}

View File

@@ -144,9 +144,14 @@ export default function ReviewPage() {
const createBookingMutation = useMutation({
mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
console.log('=== API REQUEST ===');
console.log('Endpoint:', endpoint);
console.log('Request Data:', JSON.stringify(data, null, 2));
return apiClient.post(endpoint, data);
},
onSuccess: (data: any) => {
console.log('=== API RESPONSE SUCCESS ===');
console.log('Response Data:', JSON.stringify(data, null, 2));
console.log('Booking created successfully:', data);
const bookingIdValue = data.bookingId || data.id;
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
@@ -178,7 +183,12 @@ export default function ReviewPage() {
}, 100);
},
onError: (error: any) => {
console.error('Booking creation failed:', error);
console.log('=== API RESPONSE ERROR ===');
console.error('Error Object:', error);
console.error('Error Response:', error?.response);
console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2));
console.error('Error Status:', error?.response?.status);
console.error('Error Message:', error?.message);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
alert(errorMessage);
},
@@ -196,7 +206,7 @@ export default function ReviewPage() {
console.log('Inbound schedule:', inboundSchedule);
console.log('Passengers:', passengers);
if (!seatHold?.holdId) {
if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) {
console.error('No seat hold found');
alert('Please select seats before continuing.');
router.push('/booking/seats');
@@ -275,7 +285,7 @@ export default function ReviewPage() {
bookingData = {
passengerId: passengerId,
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
holdId: seatHold.holdId,
holdId: seatHold?.holdId || '',
originStationId: searchCriteria.originStationId,
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
@@ -302,7 +312,7 @@ export default function ReviewPage() {
bookingData.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
bookingData.returnSeatClassId = returnSeatClassId;
}
@@ -314,7 +324,7 @@ export default function ReviewPage() {
// For guests: send full passenger details array
bookingData = {
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
holdId: seatHold.holdId,
holdId: seatHold?.holdId || '',
originStationId: searchCriteria.originStationId,
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
@@ -346,7 +356,7 @@ export default function ReviewPage() {
bookingData.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
bookingData.returnSeatClassId = returnSeatClassId;
}
@@ -360,7 +370,8 @@ export default function ReviewPage() {
localStorage.setItem('deviceId', bookingData.deviceId);
}
console.log('Creating booking with payload:', bookingData);
console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2));
console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
console.error('Error in handleConfirm:', error);

View File

@@ -60,11 +60,35 @@ export default function SeatsPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
const coachId = (currentSchedule as any)?.selectedCoachId;
const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode;
const { data: seatMapData, isLoading, error } = useQuery({
queryKey: ['seatmap', currentSchedule?.id, currentJourneyType],
queryFn: () => apiClient.get(`/seats/seatmap/${currentSchedule?.id}`),
enabled: !!currentSchedule?.id,
queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType],
queryFn: async () => {
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`;
console.log('🪑 Seatmap Request:', {
endpoint,
scheduleId: currentSchedule?.id,
coachId,
coachTypeCode,
currentJourneyType,
});
const response = await apiClient.get(endpoint);
console.log('✅ Seatmap Response:', {
endpoint,
fullResponse: response,
dataCoaches: (response as any)?.data?.coaches?.length || 0,
rootCoaches: (response as any)?.coaches?.length || 0,
});
const finalData = (response as any)?.data || response;
console.log('🎯 Final data structure:', finalData);
return finalData;
},
enabled: !!currentSchedule?.id && !!coachId,
});
const holdMutation = useMutation({
@@ -106,22 +130,40 @@ export default function SeatsPage() {
},
});
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
const coaches = useMemo(() => {
const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || [];
console.log('📦 Raw coaches data:', {
fromRoot: (seatMapData as any)?.coaches?.length || 0,
fromData: (seatMapData as any)?.data?.coaches?.length || 0,
using: rawCoaches.length,
seatMapData
});
return rawCoaches;
}, [seatMapData]);
const filteredCoaches = useMemo(() => {
console.log('🔍 Filtering coaches:', {
totalCoaches: coaches.length,
selectedSeatClass: currentSchedule?.selectedSeatClass,
coachesData: coaches.map((c: any) => ({
id: c.id,
name: c.name,
label: c.label,
seatClass: c.seatClass,
seatClasses: c.seatClasses,
seatsCount: c.seats?.length || 0
}))
});
const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0);
if (!currentSchedule?.selectedSeatClass) {
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
console.log('✅ No filter applied, returning all coaches:', coachesWithSeats.length);
return coachesWithSeats;
}
let filtered = coaches.filter((c: any) => {
const seatClasses = c.seatClasses || [c.seatClass] || [];
return seatClasses.some((seatClassName: string) =>
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);
console.log('✅ No seat class filter - returning all coaches with seats:', coachesWithSeats.length);
return coachesWithSeats;
}, [coaches, currentSchedule?.selectedSeatClass]);
useEffect(() => {
@@ -142,7 +184,10 @@ export default function SeatsPage() {
};
const validSeats = useMemo(() => {
let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
let seats = allSeats.filter((s: any) => {
const seatLabel = s.label || s.number || s.seatNumber || '';
return seatLabel && !seatLabel.startsWith('-');
});
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
if (isBedCoach && currentSchedule?.selectedSeatClass) {
@@ -288,10 +333,22 @@ export default function SeatsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
const parseSeatArrangement = (arrangement: string | null): number[] => {
const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => {
if (!arrangement) return [2, 2];
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
return parts.length === 2 ? parts : [2, 2];
// Check if this is a bed coach based on seat classes
const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed'));
if (isBedCoach) {
// For bed coaches, arrangement like "3+0" means 3 beds stacked vertically
// We want to render them as single column, so return [1]
const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
return parts.length > 0 ? [Math.max(...parts)] : [3];
}
// For regular seats, parse normally (e.g., "3+2" -> [3, 2])
const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
@@ -302,8 +359,7 @@ export default function SeatsPage() {
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const arrangement = parseSeatArrangement(coach.seatArrangement, coach.seatClasses || [coach.seatClass]);
if (validSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
@@ -312,36 +368,91 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
// Bed coach with bed positions (Upper, Middle, Lower)
if (isBedCoach && hasBedPositionData) {
// Group by the base seat number (column), not by row
// For beds, seats with same number but different positions should be grouped together
const seatGroups = new Map<string, any[]>();
for (const seat of validSeats) {
const baseNumber = seat.seatNumber || seat.number || seat.label || '';
if (!seatGroups.has(baseNumber)) {
seatGroups.set(baseNumber, []);
}
seatGroups.get(baseNumber)!.push(seat);
}
// Sort groups by seat number
const sortedGroups = Array.from(seatGroups.entries())
.sort(([a], [b]) => {
const numA = parseInt(a) || 0;
const numB = parseInt(b) || 0;
return numA - numB;
});
return (
<div className="space-y-2 w-40">
{validSeats.map((seat: any) => {
const rowNumber = seat.row || 1;
const shouldFlipIcon = rowNumber % 2 === 0;
<div className="space-y-4">
{sortedGroups.map(([seatNumber, beds], idx) => {
const shouldFlipIcon = idx % 2 === 0;
// Order: lower, middle, upper (bottom to top)
const orderedBeds = ['lower', 'middle', 'upper']
.map(pos => beds.find(seat => seat.bedPosition === pos))
.filter(seat => seat !== undefined);
if (orderedBeds.length === 0) return null;
return (
<div key={seat.id}>
{shouldFlipIcon && (
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
<div key={`bed-group-${seatNumber}`} className="pb-4 border-b-2 border-dashed border-gray-300 dark:border-gray-600 last:border-b-0">
<div className="flex items-center gap-3">
{shouldFlipIcon && (
<div className="flex flex-col gap-3">
{orderedBeds.map((seat: any) => {
const seatLabel = seat.seatNumber || seat.number || seat.label || '';
const bedLabelFull = seat.bedPosition ? (
seat.bedPosition === 'upper' ? 'Upper' :
seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
) : '';
return (
<div key={seat.id} className="w-20 text-xs font-bold text-foreground text-right">
{seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-3">
{orderedBeds.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={true}
bedLabel={getBedLabel(seat.bedPosition)}
coachSeatClass={seatClassStr}
/>
))}
</div>
)}
<div className="flex">
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={true}
bedLabel={getBedLabel(seat.bedPosition)}
coachSeatClass={seatClassStr}
/>
{!shouldFlipIcon && (
<div className="flex flex-col gap-3">
{orderedBeds.map((seat: any) => {
const seatLabel = seat.seatNumber || seat.number || seat.label || '';
const bedLabelFull = seat.bedPosition ? (
seat.bedPosition === 'upper' ? 'Upper' :
seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
) : '';
return (
<div key={seat.id} className="w-20 text-xs font-bold text-foreground">
{seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
</div>
);
})}
</div>
)}
</div>
{!shouldFlipIcon && (
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
)}
</div>
);
})}
@@ -349,68 +460,64 @@ export default function SeatsPage() {
);
}
const rows = [];
const processedRows = new Set();
// Regular seats with row/column arrangement
const rowMap = new Map<number, any[]>();
for (const seat of validSeats) {
if (!processedRows.has(seat.row)) {
rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
const colA = a.col.charCodeAt(0);
const colB = b.col.charCodeAt(0);
return colA - colB;
}));
processedRows.add(seat.row);
if (!rowMap.has(seat.row)) {
rowMap.set(seat.row, []);
}
rowMap.get(seat.row)!.push(seat);
}
const rows = Array.from(rowMap.entries())
.sort(([a], [b]) => a - b)
.map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], rowIdx: number) => {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const groups: any[][] = [];
// Split seats into groups based on arrangement
if (arrangement.length === 1) {
// Single group (all seats together)
groups.push(rowSeats);
} else {
// Multiple groups with aisle separation
arrangement.forEach((_groupSize, groupIdx) => {
const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0);
const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0);
const currentGroup = rowSeats.slice(startIdx, endIdx);
if (currentGroup.length > 0) groups.push(currentGroup);
});
}
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
{groups.map((group, gIdx) => (
<div key={`num-before-group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => {
const seatLabel = seat.label || seat.number || seat.seatNumber || '';
return (
<div key={`num-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground">
{seatLabel}
</div>
);
})}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={handleSeatClick}
isBedCoach={false}
bedLabel=""
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
)}
<div className="flex gap-3 justify-start">
{groups.map((group, gIdx) => (
<div key={`group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
@@ -418,35 +525,31 @@ export default function SeatsPage() {
onToggle={handleSeatClick}
isBedCoach={false}
bedLabel=""
coachSeatClass={seatClassStr}
/>
))}
</div>
)}
))}
</div>
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
{groups.map((group, gIdx) => (
<div key={`num-after-group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => {
const seatLabel = seat.label || seat.number || seat.seatNumber || '';
return (
<div key={`num-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground">
{seatLabel}
</div>
);
})}
</div>
)}
))}
</div>
)}
{showSpacing && <div className="h-2" />}
{showSpacing && <div className="h-3 border-b border-gray-200 dark:border-gray-700" />}
</div>
);
})}
@@ -456,6 +559,23 @@ export default function SeatsPage() {
if (!selectedSchedule || !passengers.length) return null;
if (!coachId) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md">
<p className="text-red-500 font-medium mb-2">No coach selected</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Please go back and select a coach type</p>
<button
onClick={() => router.push('/booking/results')}
className="btn-primary"
>
Back to Results
</button>
</div>
</div>
);
}
const allSelected = selectedSeats.length === passengers.length;
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');

View File

@@ -39,6 +39,15 @@ export interface Schedule {
availableSeats?: number;
availabilityByClass?: Record<string, number>; // API returns this
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this
coachTypes?: Array<{
coachId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{
name: string;
baseFareMinor: number;
}>;
}>;
serviceClass?: string;
status?: string;
hasAvailability?: boolean;