Merge pull request #200 from Tria-plc/alpha

Update  booking result for round trip
This commit is contained in:
Eyob T.
2026-06-17 17:02:27 +03:00
committed by GitHub
41 changed files with 2811 additions and 875 deletions

View File

@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
EMAIL_QUEUE=email_queue
SMS_QUEUE=sms_queue
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "passenger"."Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'COMPLETED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "passenger"."GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -115,6 +115,16 @@ enum BookingStatus {
@@schema("passenger")
}
enum ReturnLegStatus {
NOT_APPLICABLE // one-way booking
BOTH_USED // passenger used both legs
OUTBOUND_ONLY // return leg not used (no-show on return)
INBOUND_ONLY // outbound leg not used, return leg used
NEITHER_USED // neither leg boarded yet
@@schema("passenger")
}
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
@@ -364,7 +374,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
bookings Booking[]
bookings Booking[] @relation("OutboundSchedule")
returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
@@ -511,6 +522,9 @@ model Booking {
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
outboundBoardedAt DateTime?
returnBoardedAt DateTime?
contactEmail String?
contactPhone String?
userAgent String?
@@ -520,7 +534,8 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -1159,6 +1174,7 @@ model GateValidationLog {
ticketId String
validatorId String
gateId String?
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String
reason String?
validatedAt DateTime @default(now())

View File

@@ -34,19 +34,23 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
- **Multi-Currency Display:** Bookings track display currency and converted amounts
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
## Latest Updates
- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN).
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories.
- **Multi-Currency Display:** Bookings track display currency and converted amounts.
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
## Key Features
### 🎫 Booking Lifecycle
### Booking Lifecycle
- Search trips with real-time availability
- Age-based passenger categorization (Adult 5 years, Child <5 years)
- Age-based passenger categorization (Adult 5+ years, Child under 5)
- Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification
- Coach and seat selection with real-time availability
@@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia Djibouti)
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling
- Coach type selection with seat class and pricing options
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
- **NEW:** Display currency and converted pricing per booking
- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP)
- NEW: Display currency and converted pricing per booking
- NEW: returnLegStatus field tracks which legs of a round-trip were used
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
- Automatic Fayda verification for adults (5 years)
### Passenger Verification
1. Ethiopian Nationals:
- Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
2. **International Passengers:**
2. International Passengers:
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
### 💰 Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
### Age-Based Pricing
- ADULT (5+ years): Pay 100% of base fare
- CHILD (under 5): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations
- Example: 2 adults + 3 children = 4x base fare (first child free)
- NEW: Premium charges and insurance fees per seat class
- NEW: Transparent fee breakdown in pricing calculations
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
### Payment Integration
1. Ethiopian Payment Methods: Telebirr, CBE Birr
2. Djiboutian Payment Methods: Waafi
3. International Payment Methods: Card, Wallet
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
3. **International Payment Methods:**
- **Card** - International card payments (Visa, Mastercard)
- **Wallet** - Internal wallet system
### 🪑 Seat Management
### Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display
- NEW: Sequence-based coach ordering for consistent display
### 🎟️ Ticketing
### Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### Round-Trip Leg Tracking (NEW)
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN)
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
### 💰 Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### 📍 Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### 🔒 Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### 👤 Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- **NEW:** Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
### 🚌 Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking
### Round-Trip Booking
- One-way and round-trip journey options
- Flexible return date selection
- Combined pricing for outbound + return legs
- Separate seat management per leg
- Independent modification/cancellation per leg
- Return journey tracking and notifications
- **NEW:** Booking type stored for analytics and reporting
- NEW: Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- NEW: Complete demographic data for personalized services
### Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
- NEW: Multi-currency display per booking (ETB, DJF, USD)
### Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
### Coach Type & Class Selection
- Browse available coach types per route
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
- Deferred pricing at seat selection stage
- Coach amenities and features display
- **NEW:** Sequence-based coach ordering for consistent UI
- **NEW:** Premium and insurance fee transparency per class
- NEW: Sequence-based coach ordering for consistent UI
- NEW: Premium and insurance fee transparency per class
### 📊 Data Organization
- **Stations:** Ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options
### Data Organization
- Stations ordered by sequence (1-15) for consistent route display
- Coaches ordered by sequence (1+) per type for predictable configuration
- Booking history sorted chronologically with filtering options
## Authentication
@@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5 years)
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
\`POST /passengers/register-international\` - Passport information collection
@@ -265,7 +268,7 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
@@ -291,7 +294,7 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')

View File

@@ -75,21 +75,24 @@ export class BookingsController {
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
description: 'Returns paginated list of bookings with search and status filters'
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
status,
returnLegStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});

View File

@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters {
search?: string;
status?: string;
returnLegStatus?: string;
page?: number;
pageSize?: number;
}
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
];
}
if (status) {
where.status = status;
}
if (status) where.status = status;
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -225,6 +231,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
@@ -391,6 +399,7 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
@@ -406,7 +415,7 @@ export class BookingsService {
displayCurrency
}))
}
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
@@ -561,37 +570,24 @@ export class BookingsService {
): Promise<number> {
const now = new Date();
// Get schedule with route info
// 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: true },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
// Try segment fare rule first (most specific) if route info available
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
// Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: nationality || null,
nationality: nationality ?? null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (segmentFare) {
return segmentFare.baseFareMinor;
}
// If no segment fare with nationality, try without nationality filter
if (nationality) {
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
}) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
@@ -599,37 +595,43 @@ export class BookingsService {
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (segmentFareAny) return segmentFareAny.baseFareMinor;
}
}) : null);
if (segmentFare) return segmentFare.baseFareMinor;
}
// Fall back to fare rules if no segment fare found
// 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
const bestMatch = this.selectBestFareRule(
candidates,
scheduleId,
segmentRoute,
fullRoute,
// 3. FareEngine — distance × rate-per-km from the schedule's route
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
);
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
return bestMatch?.baseFareMinor ?? 35000;
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
}
async getByRef(bookingRef: string) {
@@ -646,7 +648,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType, createdAt: booking.createdAt,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
@@ -751,6 +757,37 @@ export class BookingsService {
}
}
// Mark round-trip bookings where the return train has departed but the return leg
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
@Cron(CronExpression.EVERY_MINUTE)
async markReturnLegNoShows() {
const now = new Date();
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
const candidates = await this.prisma.booking.findMany({
where: {
bookingType: 'ROUND_TRIP',
status: 'CONFIRMED',
returnLegStatus: 'NEITHER_USED' as any,
outboundBoardedAt: { not: null },
returnBoardedAt: null,
returnScheduleId: { not: null },
},
include: { returnSchedule: { select: { departureAt: true } } },
} as any);
for (const b of candidates) {
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
if (returnDep && returnDep < graceCutoff) {
await this.prisma.booking.update({
where: { id: b.id },
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
});
}
}
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,

View File

@@ -4,9 +4,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid' })
@ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' })
@IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' })
@IsOptional() @IsString() returnSeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string;
@@ -36,24 +39,42 @@ export class GuestPassengerDto {
}
export class CreateGuestBookingDto {
@ApiProperty({ example: 'schedule-uuid' })
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' })
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP';
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'hold-uuid' })
@ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' })
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' })
@IsOptional() @IsString() returnSeatClassId?: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@@ -66,7 +87,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@IsOptional() @IsString() password?: string;
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
) {}
async createGuestBooking(dto: CreateGuestBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') {
return this.createGuestRoundTripBooking(dto);
}
return this.createGuestOneWayBooking(dto);
}
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
// Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger
const firstPassenger = passengersData[0];
let guestPassenger = null;
let userId = null;
let createdAccount = false;
// Optional account creation
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
throw new BadRequestException('Email already registered. Please login instead.');
}
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash,
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
userId = user.id;
createdAccount = true;
} else {
// Create anonymous guest passenger with minimal data
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Check if email exists and use a unique guest email if it does
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
// Email exists, use guest email instead for anonymous booking
guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
}
// Use a guaranteed-unique guest phone to avoid constraint collisions
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existingPhone) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
}
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
// Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
@@ -302,6 +242,258 @@ export class GuestBookingService {
};
}
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
}
// Validate both holds
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId
for (const p of dto.passengers) {
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
}
// Load both schedules
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
// Process passengers (verify identity once — same person travels both legs)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
nationality = 'Ethiopian';
} else {
nationality = nationality || 'Other';
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Calculate fares for both legs
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Create or resolve guest passenger (same as one-way)
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId);
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT
? outboundBaseFare + returnBaseFare
: (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0),
displayCurrency,
})),
},
} as any,
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
createdAccount,
userId,
fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare,
returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
},
};
}
private async resolveGuestPassenger(
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
firstPassenger: any,
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash: await bcrypt.hash(dto.password, 10),
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
return { guestPassenger, userId: user.id, createdAccount: true };
}
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existing) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
return { guestPassenger, userId: null, createdAccount: false };
}
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +535,8 @@ export class GuestBookingService {
nationality?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
@@ -368,14 +562,34 @@ export class GuestBookingService {
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
}
return 35000; // Default fallback
// 2. FareEngine — distance × rate-per-km from the schedule's route
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
}
}

View File

@@ -1,8 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendEmail {
@ApiProperty()
@IsEmail()
@IsNotEmpty()
to: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceName?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
subject: string;
body: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
html?: string;
templateKey?: string;
context?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional()
@IsOptional()
body?: string;
@ApiPropertyOptional()
@IsOptional()
context?: Record<string, any>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
templateName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
replyTo?: string;
}

View File

@@ -1,9 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SendMessage {
@ApiProperty()
@IsNotEmpty()
@IsString()
to: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
from?: string;
}
export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages: SendMessage[];
}

View File

@@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap {
private readonly emailServiceClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailServiceClient
.connect()
.then(() => this.logger.log('Connected to Email service'))
@@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap {
}
async sendEmail(dto: SendEmail) {
this.emailServiceClient.emit('send-email', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
if (!this.enabled) return {};
this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' });
return {};
}
}

View File

@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { SendMessage } from './dtos/sms.dto';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -56,6 +56,15 @@ export class NotificationsController {
return this.smsClient.sendSms(dto);
}
@Post('send/sms/bulk')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
@ApiBody({ type: BulkMessagesDto })
sendBulkSms(@Body() dto: BulkMessagesDto) {
return this.smsClient.sendBulkMessages(dto);
}
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')

View File

@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@@ -11,34 +10,24 @@ import { SmsClientService } from './sms-client.service';
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ClientsModule.registerAsync([
ClientsModule.register([
{
name: 'EMAIL_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.EMAIL_QUEUE ?? 'email_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
{
name: 'SMS_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.SMS_QUEUE ?? 'sms_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
]),
],

View File

@@ -20,7 +20,7 @@ export class NotificationsService {
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
@@ -107,7 +107,7 @@ export class NotificationsService {
await this.emailClient.sendEmail({
to: passenger.user.email,
subject: this.sanitize(dto.title),
body: this.sanitize(dto.body),
text: this.sanitize(dto.body),
});
}

View File

@@ -1,4 +1,9 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
@@ -8,29 +13,32 @@ export class SmsClientService implements OnApplicationBootstrap {
constructor(
@Inject('SMS_SERVICE')
private readonly smsClient: ClientProxy,
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => this.logger.log('Connected to SMS service'))
.catch((err) => this.logger.error('Error connecting to SMS service', err));
.then(() => {
this.logger.log('connected to SMS service');
})
.catch((err) => {
console.error('Error happened at SMS service', err);
});
}
async sendSms(dto: SendMessage) {
this.smsClient.emit('send-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
if (!this.enabled) return {};
this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
this.smsClient.emit('ozeking-bulk-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
if (!this.enabled) return {};
this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
return {};
}
}

View File

@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}
export class BulkSchedulesResponseDto {

View File

@@ -44,6 +44,15 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// Assign coaches if provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);

View File

@@ -244,7 +244,8 @@ export class SearchService {
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
const baseFareMinor = bestMatch?.baseFareMinor
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
@@ -379,11 +380,8 @@ export class SearchService {
}
}
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
baseFareMinor: this.getDefaultFareForClass(sc.name),
}));
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return [];
}
private async buildCoachTypeDetails(
@@ -420,11 +418,10 @@ export class SearchService {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
if (!fareInfo) return null;
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
})
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
@@ -442,22 +439,28 @@ export class SearchService {
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,
'Economy Bed': 49000,
'VIP Bed': 63000,
};
return defaults[className] ?? 35000;
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
});
return fare.baseFarePerPassengerMinor;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'Economy Regular': 45000,
'Economy Bed': 65000,
'VIP Bed': 95000,
};
return fares[seatClassName] ?? 45000;
private getDefaultFareForClass(_className: string): never {
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
}
private defaultFare(_seatClassName: string): never {
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(

View File

@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,6 +30,10 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
@@ -77,14 +81,26 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
},
},
})
validate(
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
@Body('gateId') gateId?: string,
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
) {
return this.service.validate(ref, validatorId, gateId);
return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +122,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
@ApiOperation({
summary: 'Batch import offline validations',
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
})
@ApiBody({
schema: {
type: 'object',
properties: {
validations: {
type: 'array',
items: {
type: 'object',
required: ['bookingRef', 'validatorId', 'validatedAt'],
properties: {
bookingRef: { type: 'string' },
validatorId: { type: 'string' },
gateId: { type: 'string' },
validatedAt: { type: 'string', format: 'date-time' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
},
},
},
},
},
})
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}

View File

@@ -7,6 +7,7 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: 'OUTBOUND' | 'RETURN';
}
@Injectable()
@@ -49,6 +50,10 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
@@ -213,24 +218,69 @@ export class TicketsService {
};
}
async validate(bookingRef: string, validatorId: string, gateId?: string) {
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
// For one-way bookings use the original single-validation guard
if (!isRoundTrip) {
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' },
});
throw new BadRequestException('Ticket already validated');
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
}
// Round-trip: track which leg is being boarded
const resolvedLeg = leg ?? 'OUTBOUND';
const now = new Date();
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
});
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
// Stamp the ticket's first validation
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
}
} else {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
});
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
}
// Derive the new composite status
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
});
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },
@@ -256,6 +306,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
@@ -265,11 +317,13 @@ export class TicketsService {
const processedRefs = new Set<string>();
for (const v of validations) {
if (processedRefs.has(v.bookingRef)) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(v.bookingRef);
processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +340,21 @@ export class TicketsService {
continue;
}
if (ticket.validatedAt) {
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
results.duplicate++;
continue;
}
// For round-trip, check per-leg duplication
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const alreadyUsed =
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
if (alreadyUsed) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +365,24 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
},
} as any,
});
// update returnLegStatus for round-trip offline validations
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const bookingData: Record<string, any> =
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
results.success++;
} catch (err) {
results.failed++;

View File

@@ -140,7 +140,7 @@ export default function BookingsPage() {
},
{
key: 'bookingType',
label: 'Class',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},

View File

@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const queryClient = useQueryClient();
// Coach Types Queries
@@ -252,12 +252,17 @@ export default function CoachesPage() {
};
const confirmDelete = async () => {
try {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
}
};
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
@@ -536,6 +541,8 @@ export default function CoachesPage() {
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
error={deleteConfirm.error}
warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'

View File

@@ -362,12 +362,16 @@ export default function RoutesPage() {
type="text"
name="code"
className="input"
value={generateRouteCode(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.code : undefined}
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
required
placeholder="Select stations to generate"
disabled={!!editingRoute}
/>
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
</p>
)}
</div>
<div>
<label className="label">Route Name *</label>
@@ -375,11 +379,16 @@ export default function RoutesPage() {
type="text"
name="name"
className="input"
value={generateRouteName(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.name : undefined}
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
required
placeholder="Select stations to generate"
/>
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
</p>
)}
</div>
</div>

View File

@@ -318,8 +318,8 @@ export default function SchedulesPage() {
label: 'Train',
sortable: true,
render: (schedule: Schedule) => (
<div className="font-medium">
{schedule.train?.name} ({schedule.train?.number})
<div className="font-medium font-mono">
{schedule.train?.number}
</div>
),
},

View File

@@ -548,8 +548,11 @@ export default function SeatsPage() {
{coachesWithSeats.map((coach: any, index: number) => {
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
const coachTypeName = coachData?.coachType?.type || 'Coach';
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
coachTypeName.toLowerCase().includes('bed') ||
(coach.seats || []).some((s: any) => s.bedPosition);
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';

View File

@@ -204,15 +204,31 @@ export default function TicketsPage() {
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.boardedAt ? (
ticket.validatedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'returnLegStatus',
label: 'Return Leg',
render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>;
const map: Record<string, { label: string; cls: string }> = {
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
};
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
},
},
];
const actions = [
@@ -444,10 +460,30 @@ export default function TicketsPage() {
</div>
</div>
{selectedTicket.boardedAt && (
{selectedTicket.validatedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Boarded At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
<p className="text-sm text-muted-foreground">Validated At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
</div>
)}
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Leg Status</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Return Boarded</p>
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
</div>
</div>
</div>
)}

View File

@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
isLoading?: boolean;
isDanger?: boolean;
warning?: string;
error?: string;
}
export default function ConfirmDialog({
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
isLoading = false,
isDanger = false,
warning,
error,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
</div>
</div>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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,258 @@ 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}
<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>
<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}
{/* 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 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>
{selectedSchedule?.selectedSeatClassName && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Class
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
{/* 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 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="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="flex justify-between">
{/* 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">
Passengers
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{passengers.length} passenger
{passengers.length !== 1 ? "s" : ""}
{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>

View File

@@ -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>
</>

View File

@@ -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
// 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" />

View File

@@ -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,14 +81,47 @@ export default function ReviewPage() {
useEffect(() => {
const fetchSeatDetails = async () => {
if (!selectedSchedule?.id) return;
try {
const details: Record<string, string> = {};
// 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 || []);
const details: Record<string, string> = {};
passengers.forEach(p => {
if (p.seatId) {
const seat = allSeats.find((s: any) => s.id === p.seatId);
@@ -95,6 +130,8 @@ export default function ReviewPage() {
}
}
});
}
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 (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'}
{/* 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>
)}
{/* 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>
<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-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 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">
{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

View File

@@ -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 (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;

View File

@@ -30,7 +30,13 @@
}
.btn-ghost:hover {
@apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
background-color: rgba(20, 113, 76, 0.1);
}
@media (prefers-color-scheme: dark) {
.btn-ghost:hover {
background-color: rgba(20, 113, 76, 0.2);
}
}
.input-field {
@@ -108,13 +114,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;
@@ -137,6 +152,17 @@
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -158,11 +184,6 @@
animation: slide-in-right 0.5s ease-out;
}
@keyframes slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up {
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}

View File

@@ -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,25 +13,31 @@ 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">
@@ -40,19 +47,16 @@ export default function AppHeader() {
{/* Logo */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
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>

View File

@@ -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"

View File

@@ -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,