Updated the passenger web portal and added more endpoints to the api

This commit is contained in:
Stephanos A
2026-06-02 14:51:12 +03:00
parent 5059a1fe58
commit ed30ab0cda
34 changed files with 3396 additions and 674 deletions

View File

@@ -314,9 +314,34 @@ Content-Type: application/json
}
```
#### 4. Register International Passenger
#### 4. Universal Passenger Registration (NEW)
```bash
POST /passengers/register-international
# Guest Ethiopian with Fayda verification
POST /passengers/register
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567",
"deviceId": "device-uuid-123"
}
# Logged-in user with JWT token
POST /passengers/register
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567"
}
# International passenger (passport)
POST /passengers/register
Content-Type: application/json
{
@@ -326,11 +351,42 @@ Content-Type: application/json
"passportCountry": "Kenya",
"nationality": "Kenyan",
"phone": "+254712345678",
"email": "john@example.com"
"email": "john@example.com",
"deviceId": "device-uuid-123"
}
```
#### 5. Search Trips
#### 5. Get User Profile (NEW)
```bash
GET /auth/profile
Authorization: Bearer <jwt-token>
# Response includes user, passenger, loyalty, and wallet details
{
"id": "uuid",
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"role": "PASSENGER",
"nationality": "Ethiopian",
"faydaVerified": true,
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
"passenger": {
"id": "uuid",
"loyalty": {
"tier": "SILVER",
"pointsBalance": 1500,
"lifetimePoints": 3000
},
"wallet": {
"balanceMinor": 50000,
"currency": "ETB"
}
}
}
```
#### 6. Search Trips
```bash
POST /search
Content-Type: application/json
@@ -344,7 +400,7 @@ Content-Type: application/json
}
```
#### 6. Get Fare Quote
#### 7. Get Fare Quote
```bash
POST /search/fare-quote
Content-Type: application/json
@@ -373,7 +429,7 @@ Content-Type: application/json
}
```
#### 7. Guest Booking (No Login Required)
#### 8. Guest Booking (No Login Required)
```bash
POST /bookings/guest
Content-Type: application/json
@@ -398,7 +454,7 @@ Content-Type: application/json
}
```
#### 8. Agent Booking (IAM Auth)
#### 9. Agent Booking (IAM Auth)
```bash
POST /agents/bookings
Authorization: Bearer <iam-token>

View File

@@ -210,31 +210,32 @@ Payment providers send notifications to:
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking and management")
.addTag("Auth", "Registration and login")
.addTag("Booking", "Booking lifecycle")
.addTag("Dashboard", "Home dashboard aggregate")
.addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency")
.addTag("Fleet", "Train services and coaches")
.addTag("Fraud Detection", "Fraud detection and monitoring")
.addTag("Live Tracking", "Real-time trip status and crowd signals")
.addTag("Loyalty", "Points, tiers, and rewards")
.addTag("Notifications", "Push and email notifications")
.addTag("Passenger", "Profiles, traveler profiles, saved routes")
.addTag("Payment", "Payment intents and refunds")
.addTag("Payment Webhooks", "Endpoints for payment provider notifications")
.addTag("Promotions", "Promo codes and campaigns")
.addTag("Reports", "Sales and operational reports")
.addTag("Routes", "Route information and management")
.addTag("Schedule", "Trips and fare rules")
.addTag("Search", "Trip search and fare quotes")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed")
.addTag("Seats", "Seat maps and holds")
.addTag("Segment-based Seats", "Seats assigned and released by trip segments")
.addTag("Stations", "Station directory")
.addTag("Support", "FAQ and chat support")
.addTag("Tickets", "QR ticket generation and validation")
.addTag("Wallet", "Wallet balance and ledger")
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();
@@ -246,6 +247,8 @@ Payment providers send notifications to:
persistAuthorization: true,
docExpansion: "none",
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
});

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@Controller('auth')
@@ -78,4 +79,142 @@ export class AuthController {
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Logout current user',
description: `Logout the authenticated user and invalidate their session.
### What happens:
- Invalidates the current session token
- Records logout in audit log
- Frontend should clear stored token and redirect to home
### Authentication:
- **Required**: JWT Bearer Token
- Token will be invalidated after successful logout`
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
example: {
success: true,
message: 'Logged out successfully'
}
}
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
logout(@Request() req: any) {
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.logout(req.user.userId);
}
@Get('profile')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current user profile',
description: `**Returns complete user profile with all connected data**
---
### Response Includes
#### User Information
- Basic details (id, email, phone, fullName, role)
- Nationality and document information
- Fayda verification status
- Account timestamps (created, last login)
#### Passenger Data (if role=PASSENGER)
- Passenger ID and preferences
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### User Preferences
- Language, notification settings, etc.
---
### Use Cases
1. **App Initialization**: Fetch on app load to get user context
2. **Profile Pre-fill**: Use data to auto-fill booking forms
3. **Verification Check**: Check \`faydaVerified\` before registration
4. **Loyalty Display**: Show tier and points in UI
5. **Wallet Balance**: Display available balance
---
### Authentication
- **Required**: JWT Bearer Token
- Token must be valid and not expired
- Returns profile for authenticated user only`,
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
schema: {
example: {
id: 'user-uuid-123',
email: 'kelemu@email.com',
phone: '+251911234567',
fullName: 'Kelemu Abebe',
role: 'PASSENGER',
nationality: 'Ethiopian',
nationalityCode: 'ET',
nationalId: null,
passportNumber: null,
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
lastLoginAt: '2024-01-20T14:22:00.000Z',
createdAt: '2023-12-01T08:00:00.000Z',
passenger: {
id: 'passenger-uuid-456',
preferredLanguage: 'am',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
}
},
preferences: {
emailNotifications: true,
smsNotifications: true,
language: 'am'
}
}
}
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing JWT token',
schema: {
example: {
statusCode: 401,
message: 'Unauthorized'
}
}
})
getProfile(@Request() req: any) {
console.log('Profile request - User from JWT:', req.user);
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.getProfile(req.user.userId);
}
}

View File

@@ -143,4 +143,70 @@ export class AuthService {
data: { userId, action, entityType, entityId, oldData, newData }
});
}
async getProfile(userId: string) {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
passenger: {
include: {
loyalty: true,
wallet: true,
},
},
preferences: true,
},
});
if (!user) throw new UnauthorizedException('User not found');
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
role: user.role,
nationality: user.nationality,
nationalityCode: user.nationalityCode,
nationalId: user.nationalId,
passportNumber: user.passportNumber,
faydaVerified: user.faydaVerified,
faydaVerifiedAt: user.faydaVerifiedAt,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
passenger: user.passenger ? {
id: user.passenger.id,
preferredLanguage: user.passenger.preferredLanguage,
loyalty: user.passenger.loyalty ? {
tier: user.passenger.loyalty.tier,
pointsBalance: user.passenger.loyalty.pointsBalance,
lifetimePoints: user.passenger.loyalty.lifetimePoints,
} : null,
wallet: user.passenger.wallet ? {
balanceMinor: user.passenger.wallet.balanceMinor,
currency: user.passenger.wallet.currency,
} : null,
} : null,
preferences: user.preferences,
};
}
async logout(userId: string) {
// Invalidate all active sessions for this user
await this.prisma.session.deleteMany({
where: { userId }
});
// Log the logout action
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
return {
success: true,
message: 'Logged out successfully'
};
}
}

View File

@@ -10,7 +10,7 @@ export class PassengerInputDto {
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
}

View File

@@ -22,7 +22,7 @@ export class GuestPassengerDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' })
@IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' })
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' })
@IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda), Djiboutian, Other' })

View File

@@ -71,28 +71,42 @@ export class GuestBookingService {
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
// Verifayda verification ONLY for Ethiopian nationals with National ID
const isEthiopian = !passenger.nationality || passenger.nationality === 'Ethiopian' ||
(passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !passenger.passportCountry);
// Determine if passenger is Ethiopian
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(
`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`
);
// Ethiopian with National ID
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
// Attempt Fayda verification
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;
}
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
}
// International passenger with Passport (non-Ethiopian)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Passport details are required for international passengers
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 (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !isEthiopian) {
// Non-Ethiopian with national ID (e.g., Djiboutian national ID)
}
// Ethiopian with Passport (manual entry without Fayda)
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Ethiopians can use passport instead of national ID
nationality = 'Ethiopian';
}
// International with National ID (e.g., Djiboutian national ID)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
nationality = nationality || 'Other';
}
@@ -175,11 +189,23 @@ export class GuestBookingService {
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`;
}
}
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email || `guest-${Date.now()}@edr-platform.com`,
phone: firstPassenger.phone || `+251${Date.now()}`,
email: guestEmail,
phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},

View File

@@ -1,12 +1,13 @@
import { Body, Controller, Get, Param, Post, UseGuards, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@ApiTags('Passenger')
@ApiTags('Passengers')
@Controller('passengers')
export class PassengersController {
constructor(
@@ -56,14 +57,51 @@ export class PassengersController {
@Post('verify-fayda')
@ApiOperation({
summary: 'Verify Ethiopian national ID via Verifayda 2.0',
description: `Verifies Ethiopian national ID and retrieves passenger data from government database.
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs**
---
### Purpose
Pre-verify national ID to auto-fill passenger registration form before submission.
---
### Flow
1. User enters national ID in form
2. Frontend calls \`POST /passengers/verify-fayda\`
3. API queries Verifayda 2.0 government database
4. Returns verified passenger data (name, DOB, gender)
5. Frontend auto-fills form with verified data
6. User submits form via \`POST /passengers/register\`
---
### Features
- Real-time verification via Verifayda 2.0 API
- Retrieves verified passenger data (name, DOB, gender, nationality)
- National IDs NOT stored (policy compliant)
- Retrieves verified data: name, date of birth, gender, nationality
- **National IDs NOT stored** (policy compliant)
- Only for Ethiopian nationals with national ID
- Non-Ethiopians should use passport (no verification required)
- Returns passenger details for booking form auto-fill`,
- Non-Ethiopians use passport (no verification)
---
### Important Notes
- This is a **read-only** verification endpoint
- Does NOT save passenger data to database
- Use \`POST /passengers/register\` to actually register
- Falls back to manual entry if Verifayda disabled or fails
---
### Authentication
- **Public endpoint** (no authentication required)
- Can be called before login/registration`,
})
@ApiResponse({
status: 200,
@@ -85,21 +123,143 @@ export class PassengersController {
return this.verifaydaService.verifyNationalId(dto.nationalId);
}
@Post('register-international')
@Post('register')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Register international passenger with passport details',
description: `Saves international passenger profile for booking.
summary: 'Universal passenger registration endpoint',
description: `**Single endpoint for all passenger registration scenarios**
- For non-Ethiopian passengers (Djiboutian, Kenyan, etc.)
- Collects passport information
- No government verification required
- Profile saved for future bookings
- Can be used by logged-in users or guest users (via deviceId)`,
---
### Automatic Detection
The API automatically detects:
- **Passenger Type**: Ethiopian (nationalId) vs International (passportNumber)
- **Authentication**: Logged-in (JWT token) vs Guest (deviceId)
- **Verification**: Auto-attempts Fayda for Ethiopian nationals
---
### Scenarios Handled
#### 1. Guest Ethiopian Passenger
- Provide: \`nationalId\`, \`deviceId\`
- Behavior: Attempts Fayda verification → Saves to SavedPassengerProfile
- Response: \`verified: true/false\`, \`linked: false\`
#### 2. Guest International Passenger
- Provide: \`passportNumber\`, \`passportCountry\`, \`deviceId\`
- Behavior: No verification → Saves to SavedPassengerProfile
- Response: \`verified: false\`, \`linked: false\`
#### 3. Logged-in Ethiopian Passenger
- Provide: JWT token + \`nationalId\`
- Behavior: Attempts Fayda verification → Updates user profile
- Response: \`verified: true/false\`, \`linked: true\`
#### 4. Logged-in International Passenger
- Provide: JWT token + \`passportNumber\`, \`passportCountry\`
- Behavior: No verification → Updates user profile
- Response: \`verified: false\`, \`linked: true\`
---
### Authentication
- **Optional JWT Bearer Token** (OptionalJwtGuard)
- Token present → Links to user account
- No token → Saves as guest (requires deviceId)
---
### Benefits
- Single endpoint for all scenarios
- Auto-detects passenger type and flow
- Graceful fallback if Fayda fails
- Consistent response structure
---
### Replaces
- Manual verification + save flows`,
})
@ApiResponse({ status: 201, description: 'International passenger profile saved successfully' })
@ApiResponse({ status: 400, description: 'Invalid passport details' })
registerInternational(@Body() dto: RegisterInternationalPassengerDto) {
return this.service.registerInternational(dto);
@ApiResponse({
status: 201,
description: 'Passenger registered successfully',
schema: {
example: {
id: 'uuid-123',
passengerName: 'Abebe Kebede',
dateOfBirth: '1985-03-15T00:00:00.000Z',
nationality: 'Ethiopian',
verified: true,
linked: false,
message: 'Passenger details saved for guest booking'
}
}
})
@ApiResponse({
status: 400,
description: 'Validation error or verification failed',
schema: {
example: {
statusCode: 400,
message: 'Validation failed',
error: 'Bad Request'
}
}
})
@ApiResponse({
status: 401,
description: 'Invalid JWT token (only if token provided but invalid)'
})
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
const userId = req.user?.userId;
return this.service.registerPassenger({ ...dto, userId });
}
@Post('save-details')
@ApiOperation({
summary: '[LEGACY] Save all passenger details before seat selection',
description: `**Note:** This endpoint is legacy. Consider using \`POST /passengers/register\` instead.
Saves all passenger details to database before proceeding to seat selection.
- Required step in booking flow
- Saves details for all passengers in booking
- Supports both logged-in users and guests
- Prevents data loss if user navigates away
**Migration:** Use \`POST /passengers/register\` for new implementations.`,
})
@ApiResponse({
status: 201,
description: 'Passenger details saved successfully',
schema: {
example: {
count: 2,
passengerIds: ['uuid-1', 'uuid-2'],
passengers: [
{
id: 'uuid-1',
passengerName: 'John Doe',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Ethiopian'
},
{
id: 'uuid-2',
passengerName: 'Jane Doe',
dateOfBirth: '1992-05-15T00:00:00.000Z',
nationality: 'Ethiopian'
}
],
message: 'Passenger details saved successfully'
}
}
})
@ApiResponse({ status: 400, description: 'Validation error - passengers array required' })
savePassengers(@Body() body: any) {
const passengers = body.passengers || (Array.isArray(body) ? body : [body]);
return this.service.savePassengers(passengers, body.userId, body.deviceId);
}
@Post('traveler-profiles')

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTravelerProfileDto {
@@ -19,7 +19,15 @@ export class CreateSavedRouteDto {
}
export class VerifyFaydaDto {
@ApiProperty({ example: 'ET123456789', description: 'Ethiopian national ID number' })
@ApiProperty({
example: 'ET123456789',
description: `**Ethiopian national ID number**
- Format: Varies by Ethiopian ID system
- Example: ET123456789
- Must be valid Ethiopian national ID
- Used to query Verifayda 2.0 government database`
})
@IsString()
nationalId: string;
}
@@ -66,3 +74,184 @@ export class RegisterInternationalPassengerDto {
@IsString()
deviceId?: string;
}
export class SavePassengerDetailsDto {
@ApiProperty({ example: 'Abebe Kebede' })
@IsString()
name: string;
@ApiProperty({ example: '1985-03-15' })
@IsDateString()
dateOfBirth: string;
@ApiProperty({ example: 'ETHIOPIAN' })
@IsString()
nationality: string;
@ApiPropertyOptional({ example: 'ET123456789' })
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({ example: 'P1234567' })
@IsOptional()
@IsString()
passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya' })
@IsOptional()
@IsString()
passportCountry?: string;
@ApiPropertyOptional({ example: '+251911234567' })
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({ example: 'email@example.com' })
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
faydaSub?: string;
}
export class SavePassengersDto {
@ApiProperty({ type: [SavePassengerDetailsDto] })
passengers: SavePassengerDetailsDto[];
@ApiPropertyOptional({ description: 'User ID if logged in' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: 'Device ID for guest users' })
@IsOptional()
@IsString()
deviceId?: string;
}
export class RegisterPassengerDto {
@ApiProperty({
example: 'Abebe Kebede',
description: 'Full name of passenger (required for all scenarios)'
})
@IsString()
passengerName: string;
@ApiProperty({
example: '1985-03-15',
description: 'Date of birth in ISO format YYYY-MM-DD (required for all scenarios)'
})
@IsDateString()
dateOfBirth: string;
@ApiPropertyOptional({
example: 'ET123456789',
description: `**Ethiopian national ID number**
- Triggers automatic Fayda verification if enabled
- Use for Ethiopian nationals only
- Mutually exclusive with passportNumber
- If Fayda enabled: passenger data auto-filled from government database
- If Fayda disabled: falls back to manual entry`
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
example: 'P1234567',
description: `**Passport number**
- Required for international passengers
- Mutually exclusive with nationalId
- No verification performed (manual entry only)`
})
@IsOptional()
@IsString()
passportNumber?: string;
@ApiPropertyOptional({
example: 'Kenya',
description: 'Passport issuing country (required if passportNumber provided)'
})
@IsOptional()
@IsString()
passportCountry?: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: `**Nationality**
- Auto-filled if Fayda verification succeeds
- Required for international passengers
- Optional for Ethiopian passengers (defaults to "Ethiopian")`
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
example: '+251911234567',
description: 'Phone number in international format (optional but recommended)'
})
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({
example: 'abebe@example.com',
description: 'Email address (optional but recommended)'
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
description: `**User ID (auto-populated from JWT token)**
- Do NOT send this field in request
- Automatically extracted from JWT token if present
- Used to link passenger to user account`
})
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({
example: 'device-uuid-123',
description: `**Device ID for guest users**
- **Required if no JWT token provided (guest mode)**
- Generate once and store locally (localStorage/AsyncStorage)
- Used to retrieve saved passenger profiles
- Format: UUID or any unique string`
})
@IsOptional()
@IsString()
deviceId?: string;
@ApiPropertyOptional({
example: 'Male',
description: 'Gender (auto-filled if Fayda verification succeeds)'
})
@IsOptional()
@IsString()
gender?: string;
@ApiPropertyOptional({
example: true,
description: `**Whether to verify with Fayda (auto-determined)**
- Default: Auto-detect (true if nationalId provided)
- Set to false to skip Fayda verification (use manual entry)
- Only applicable for Ethiopian nationals with nationalId`
})
@IsOptional()
@IsBoolean()
verifyWithFayda?: boolean;
}

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
interface PassengerFilters {
search?: string;
@@ -11,7 +12,10 @@ interface PassengerFilters {
@Injectable()
export class PassengersService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private verifaydaService: VerifaydaService,
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters;
@@ -126,29 +130,43 @@ export class PassengersService {
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
}
async registerInternational(dto: RegisterInternationalPassengerDto) {
const profile = await this.prisma.savedPassengerProfile.create({
data: {
userId: dto.userId,
deviceId: dto.deviceId,
passengerName: dto.passengerName,
dateOfBirth: new Date(dto.dateOfBirth),
idDocumentType: 'PASSPORT',
passportNumber: dto.passportNumber,
passportCountry: dto.passportCountry,
nationality: dto.nationality,
phone: dto.phone,
email: dto.email,
},
});
async savePassengers(passengers: any[], userId?: string, deviceId?: string) {
if (!passengers || !Array.isArray(passengers)) {
throw new BadRequestException('Passengers array is required');
}
if (passengers.length === 0) {
throw new BadRequestException('At least one passenger is required');
}
const savedProfiles = await Promise.all(
passengers.map((p) =>
this.prisma.savedPassengerProfile.create({
data: {
userId,
deviceId,
passengerName: p.name,
dateOfBirth: new Date(p.dateOfBirth),
idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT',
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
nationality: p.nationality,
phone: p.phone,
email: p.email,
},
})
)
);
return {
id: profile.id,
passengerName: profile.passengerName,
dateOfBirth: profile.dateOfBirth,
passportNumber: profile.passportNumber,
passportCountry: profile.passportCountry,
nationality: profile.nationality,
message: 'International passenger profile saved successfully',
count: savedProfiles.length,
passengerIds: savedProfiles.map(p => p.id),
passengers: savedProfiles.map(p => ({
id: p.id,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
nationality: p.nationality,
})),
message: 'Passenger details saved successfully',
};
}
@@ -161,4 +179,95 @@ export class PassengersService {
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
}
async registerPassenger(dto: RegisterPassengerDto) {
const isEthiopian = !!dto.nationalId;
const isLoggedIn = !!dto.userId;
let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) {
try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
if (verification.verified && verification.passengerData) {
verifiedData = verification.passengerData;
}
} catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error);
}
}
// Use verified data if available, otherwise use provided data
const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
nationality: verifiedData?.nationality || dto.nationality || (isEthiopian ? 'Ethiopian' : null),
gender: verifiedData?.gender || dto.gender,
phone: dto.phone,
email: dto.email,
};
// If logged in, update user profile and link passenger
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
include: { passenger: true },
});
if (!user) {
throw new BadRequestException('User not found');
}
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
data: {
fullName: finalData.passengerName,
nationality: finalData.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber,
faydaVerified: !!verifiedData,
faydaVerifiedAt: verifiedData ? new Date() : null,
},
});
}
return {
id: user.passenger?.id || user.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,
verified: !!verifiedData,
linked: true,
message: 'Passenger details saved and linked to user account',
};
}
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({
data: {
deviceId: dto.deviceId,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
passportNumber: dto.passportNumber,
passportCountry: dto.passportCountry,
nationality: finalData.nationality,
phone: dto.phone,
email: dto.email,
},
});
return {
id: profile.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,
verified: !!verifiedData,
linked: false,
message: 'Passenger details saved for guest booking',
};
}
}

View File

@@ -277,7 +277,10 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Delete related records first
// Delete related records first (in dependency order)
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });

View File

@@ -27,7 +27,7 @@ export class SupportService {
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';

View File

@@ -14,12 +14,16 @@ interface RouteStop {
stationId: string;
sequence: number;
distanceKm?: number;
distanceFromOrigin?: number;
}
export default function RoutesPage() {
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
const [stops, setStops] = useState<RouteStop[]>([]);
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
@@ -65,21 +69,38 @@ export default function RoutesPage() {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (stops.length < 2) {
alert('Route must have at least 2 stops');
if (!originStationId || !destinationStationId) {
alert('Please select origin and destination stations');
return;
}
const stopsArray = stops.map((stop, idx) => {
const stopData: any = {
stationId: stop.stationId,
sequence: idx + 1,
};
if (idx > 0 && stop.distanceKm) {
stopData.distanceKm = stop.distanceKm;
}
return stopData;
});
if (originStationId === destinationStationId) {
alert('Origin and destination must be different');
return;
}
// Sort middle stops by distance from origin
const sortedMiddleStops = [...stops].sort((a, b) =>
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
);
// Calculate distanceKm (distance from previous stop)
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
...sortedMiddleStops.map((stop, idx) => {
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
return {
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
};
}),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
},
];
const routeData = {
code: formData.get('code') as string,
@@ -100,7 +121,7 @@ export default function RoutesPage() {
};
const addStop = () => {
setStops([...stops, { stationId: '', sequence: stops.length + 1 }]);
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
};
const removeStop = (index: number) => {
@@ -113,6 +134,20 @@ export default function RoutesPage() {
setStops(updated);
};
const generateRouteCode = (originId: string, destId: string) => {
if (!originId || !destId) return '';
const origin = stations?.items?.find((s: any) => s.id === originId);
const dest = stations?.items?.find((s: any) => s.id === destId);
return origin && dest ? `${origin.code}-${dest.code}` : '';
};
const generateRouteName = (originId: string, destId: string) => {
if (!originId || !destId) return '';
const origin = stations?.items?.find((s: any) => s.id === originId);
const dest = stations?.items?.find((s: any) => s.id === destId);
return origin && dest ? `${origin.name} - ${dest.name}` : '';
};
const handleDelete = async (route: any) => {
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
await deleteMutation.mutateAsync(route.id);
@@ -139,6 +174,35 @@ export default function RoutesPage() {
label: 'Edit',
onClick: (route: any) => {
setEditingRoute(route);
const routeStops = route.stops || [];
if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Calculate cumulative distance for destination
let cumulativeDistance = 0;
routeStops.forEach((stop: any, idx: number) => {
if (idx > 0) {
cumulativeDistance += stop.distanceKm || 0;
}
});
setDestinationDistance(cumulativeDistance);
// Calculate distance from origin for middle stops
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => {
let distFromOrigin = 0;
for (let i = 1; i <= idx + 1; i++) {
distFromOrigin += routeStops[i].distanceKm || 0;
}
return {
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: distFromOrigin,
};
});
setStops(middleStops);
}
setShowModal(true);
},
variant: 'secondary' as const,
@@ -163,6 +227,9 @@ export default function RoutesPage() {
icon={Plus}
onClick={() => {
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setShowModal(true);
}}
@@ -185,12 +252,52 @@ export default function RoutesPage() {
onClose={() => {
setShowModal(false);
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Origin Station *</label>
<select
className="input"
value={originStationId}
onChange={(e) => setOriginStationId(e.target.value)}
required
disabled={!!editingRoute}
>
<option value="">Select Origin</option>
{stations?.items?.map((station: any) => (
<option key={station.id} value={station.id}>
{station.name} ({station.code})
</option>
))}
</select>
</div>
<div>
<label className="label">Destination Station *</label>
<select
className="input"
value={destinationStationId}
onChange={(e) => setDestinationStationId(e.target.value)}
required
disabled={!!editingRoute}
>
<option value="">Select Destination</option>
{stations?.items?.map((station: any) => (
<option key={station.id} value={station.id}>
{station.name} ({station.code})
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Route Code *</label>
@@ -198,9 +305,10 @@ export default function RoutesPage() {
type="text"
name="code"
className="input"
defaultValue={editingRoute?.code}
value={generateRouteCode(originStationId, destinationStationId)}
readOnly
required
placeholder="e.g., ADD-DJI"
placeholder="Select stations to generate"
disabled={!!editingRoute}
/>
</div>
@@ -210,9 +318,10 @@ export default function RoutesPage() {
type="text"
name="name"
className="input"
defaultValue={editingRoute?.name}
value={generateRouteName(originStationId, destinationStationId)}
readOnly
required
placeholder="e.g., Addis Ababa Djibouti"
placeholder="Select stations to generate"
/>
</div>
</div>
@@ -252,56 +361,66 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops *</label>
<ActionButton
type="button"
variant="secondary"
size="sm"
icon={Plus}
onClick={addStop}
>
Add Stop
</ActionButton>
<label className="label mb-0">Route Stops</label>
</div>
{stops.length === 0 && (
<p className="text-sm text-muted-foreground mb-3">No stops added. Click "Add Stop" to begin.</p>
)}
<div className="space-y-2">
{/* Origin Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
1
</div>
<div className="flex-1 font-medium">
{originStationId ? (
<span>
{stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'}
{' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'})
</span>
) : (
<span className="text-muted-foreground">Select origin station above</span>
)}
</div>
<div className="text-sm text-muted-foreground">
0 km
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{/* Intermediate Stops */}
{stops.map((stop, index) => (
<div key={index} className="flex gap-2 items-start p-3 bg-muted/50 rounded">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{index + 1}
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{index + 2}
</div>
<div className="flex-1 grid grid-cols-2 gap-2">
<div>
<select
className="input input-sm"
value={stop.stationId}
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
required
>
<option value="">Select Station</option>
{stations?.items?.map((station: any) => (
<option key={station.id} value={station.id}>
{station.name} ({station.code})
</option>
))}
</select>
</div>
<div>
<input
type="number"
className="input input-sm"
placeholder={index === 0 ? 'Origin (0 km)' : 'Distance from previous (km)'}
value={stop.distanceKm || ''}
onChange={(e) => updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)}
disabled={index === 0}
min="0"
step="0.1"
/>
</div>
<div className="flex-1">
<select
className="input input-sm"
value={stop.stationId}
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
required
>
<option value="">Select Station</option>
{stations?.items?.filter((s: any) =>
s.id !== originStationId &&
s.id !== destinationStationId &&
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
).map((station: any) => (
<option key={station.id} value={station.id}>
{station.name} ({station.code})
</option>
))}
</select>
</div>
<div className="w-32">
<input
type="number"
className="input input-sm"
placeholder="km"
value={stop.distanceFromOrigin || ''}
onChange={(e) => updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)}
min="0"
step="0.1"
required
/>
</div>
<button
type="button"
@@ -312,6 +431,52 @@ export default function RoutesPage() {
</button>
</div>
))}
{/* Add Intermediate Stop Button */}
{originStationId && destinationStationId && (
<div className="flex justify-center py-2">
<ActionButton
type="button"
variant="secondary"
size="sm"
icon={Plus}
onClick={addStop}
>
Add Intermediate Stop
</ActionButton>
</div>
)}
{/* Destination Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{stops.length + 2}
</div>
<div className="flex-1 font-medium">
{destinationStationId ? (
<span>
{stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
{' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
</span>
) : (
<span className="text-muted-foreground">Select destination station above</span>
)}
</div>
<div className="w-32">
{destinationStationId && (
<input
type="number"
className="input input-sm"
placeholder="km"
value={destinationDistance || ''}
onChange={(e) => setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)}
min="0"
step="0.1"
required
/>
)}
</div>
</div>
</div>
</div>
@@ -322,6 +487,9 @@ export default function RoutesPage() {
onClick={() => {
setShowModal(false);
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
}}
>

View File

@@ -77,6 +77,14 @@ export default function SchedulesPage() {
},
});
const removeCoachMutation = useMutation({
mutationFn: ({ scheduleId, coachId }: { scheduleId: string; coachId: string }) =>
schedulesApi.removeCoachAssignment(scheduleId, coachId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -119,6 +127,12 @@ export default function SchedulesPage() {
setShowCoachModal(true);
};
const handleRemoveCoach = async (schedule: any, coachId: string) => {
if (confirm('Remove this coach from the schedule?')) {
await removeCoachMutation.mutateAsync({ scheduleId: schedule.id, coachId });
}
};
const handleToggleCoach = (coachId: string) => {
setSelectedCoaches(prev => {
const exists = prev.find(c => c.coachId === coachId);
@@ -157,9 +171,21 @@ export default function SchedulesPage() {
return (
<div className="flex flex-wrap gap-1">
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => (
<Badge key={assignment.id} variant="status" status="CONFIRMED">
{assignment.coach?.coachNumber || 'N/A'}
</Badge>
<div key={assignment.id} className="group relative inline-flex">
<Badge variant="status" status="CONFIRMED">
{assignment.coach?.coachNumber || 'N/A'}
</Badge>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveCoach(schedule, assignment.coach.id);
}}
className="absolute -top-1 -right-1 hidden group-hover:flex items-center justify-center w-4 h-4 bg-destructive text-destructive-foreground rounded-full text-xs"
title="Remove coach"
>
×
</button>
</div>
))}
{coachCount > 3 && (
<Badge variant="status" status="PENDING">

View File

@@ -3,13 +3,17 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { Search, Armchair, Lock, Unlock } from 'lucide-react';
import Badge from '@/components/ui/Badge';
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
export default function SeatsPage() {
const [search, setSearch] = useState('');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [showBlockModal, setShowBlockModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState('');
const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({
@@ -17,114 +21,85 @@ export default function SeatsPage() {
queryFn: () => schedulesApi.getAll(),
});
const { data, isLoading } = useQuery({
queryKey: ['seats', selectedSchedule],
queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]),
const { data: seatMapData, isLoading } = useQuery({
queryKey: ['seatmap', selectedSchedule],
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
enabled: !!selectedSchedule,
});
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowBlockModal(false);
setSelectedSeat(null);
setBlockReason('');
},
});
const unblockMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
},
});
const seats = Array.isArray(data) ? data : data?.items || data?.data || [];
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
const columns = [
{
key: 'seatNumber',
label: 'Seat Number',
render: (seat: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Armchair className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{seat.seatNumber}</span>
</div>
),
},
{
key: 'coach',
label: 'Coach',
render: (seat: any) => (
<span className="text-sm">{seat.coach?.coachNumber || 'N/A'}</span>
),
},
{
key: 'seatClass',
label: 'Class',
render: (seat: any) => {
const seatClass = seat.coach?.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'position',
label: 'Position',
render: (seat: any) => (
<span className="text-sm text-muted-foreground">
{seat.position || seat.seatPosition || 'N/A'}
</span>
),
},
{
key: 'status',
label: 'Status',
render: (seat: any) => {
const isBlocked = seat.isBlocked || seat.status === 'BLOCKED';
const isBooked = seat.isBooked || seat.status === 'BOOKED';
if (isBlocked) return <span className="edr-badge edr-badge-danger">Blocked</span>;
if (isBooked) return <span className="edr-badge edr-badge-warning">Booked</span>;
return <span className="edr-badge edr-badge-success">Available</span>;
},
},
];
const handleBlock = (seat: any) => {
setSelectedSeat(seat);
setShowBlockModal(true);
};
const actions = [
{
label: 'Block',
onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }),
variant: 'secondary' as const,
icon: Lock,
show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED',
},
{
label: 'Unblock',
onClick: (seat: any) => unblockMutation.mutate(seat.id),
variant: 'secondary' as const,
icon: Unlock,
show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED',
},
];
const handleUnblock = async (seat: any) => {
if (confirm('Are you sure you want to unblock this seat?')) {
await unblockMutation.mutateAsync(seat.id);
}
};
const submitBlock = async () => {
if (!blockReason.trim()) {
alert('Please provide a reason for blocking');
return;
}
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
};
const getSeatStatus = (seat: any) => {
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
if (seat.status === 'HELD') return 'HELD';
return 'AVAILABLE';
};
const getSeatColor = (status: string) => {
switch (status) {
case 'AVAILABLE': return 'bg-green-500';
case 'BOOKED': return 'bg-red-500';
case 'HELD': return 'bg-yellow-500';
case 'BLOCKED': return 'bg-gray-500';
default: return 'bg-gray-300';
}
};
const filteredCoaches = coaches.filter((coach: any) =>
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
<p className="text-muted-foreground mt-1">Manage seat availability and blocking</p>
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
</div>
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="flex-1">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
@@ -133,41 +108,191 @@ export default function SeatsPage() {
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeCode = schedule.route?.code || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeCode} - {date}
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search seats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
<label className="label">Search Coaches</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
</div>
{selectedSchedule ? (
<DataTable
columns={columns}
data={seats}
actions={actions}
loading={isLoading}
/>
) : (
{!selectedSchedule ? (
<div className="text-center py-12 text-muted-foreground">
Select a schedule to view seats
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
</div>
) : isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : filteredCoaches.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No coaches found for this schedule</p>
</div>
) : (
<div className="space-y-6">
{/* Legend */}
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
<span className="text-sm">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500"></div>
<span className="text-sm">Booked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-yellow-500"></div>
<span className="text-sm">Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span>
</div>
</div>
{/* Coaches */}
{filteredCoaches.map((coach: any) => {
const seats = coach.seats || [];
const seatClass = coach.seatClass?.name || 'N/A';
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
return (
<div key={coach.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">
Coach {coach.coachNumber} - {coach.label}
</h3>
<p className="text-sm text-muted-foreground">
{seatClass} {seats.length} seats
</p>
</div>
<div className="flex gap-3 text-sm">
<span className="text-green-600">Available: {availableCount}</span>
<span className="text-red-600">Booked: {bookedCount}</span>
<span className="text-gray-600">Blocked: {blockedCount}</span>
</div>
</div>
<div className="grid grid-cols-8 gap-2">
{seats.map((seat: any) => {
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div
key={seat.id}
className="relative group"
>
<div
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
title={`${seat.seatNumber} - ${status}`}
>
{seat.seatNumber}
</div>
{(canBlock || canUnblock) && (
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
{canBlock && (
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Block Modal */}
<Modal
isOpen={showBlockModal}
onClose={() => {
setShowBlockModal(false);
setSelectedSeat(null);
setBlockReason('');
}}
title="Block Seat"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason for Blocking *</label>
<textarea
className="input"
rows={3}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
placeholder="e.g., Maintenance required, Damaged seat, Reserved for staff"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowBlockModal(false);
setSelectedSeat(null);
setBlockReason('');
}}
>
Cancel
</ActionButton>
<ActionButton
onClick={submitBlock}
loading={blockMutation.isPending}
disabled={!blockReason.trim()}
>
Block Seat
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { ReactNode, useState } from 'react';
import { ReactNode, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronUp, ChevronDown, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import ActionButton from './ActionButton';
@@ -42,6 +43,22 @@ export default function DataTable<T extends Record<string, any>>({
}: DataTableProps<T>) {
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
const [expandedActions, setExpandedActions] = useState<string | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
const isButtonClick = Object.values(buttonRefs.current).some(ref => ref?.contains(target));
const isDropdownClick = document.querySelector('[data-dropdown-menu]')?.contains(target);
if (expandedActions && !isButtonClick && !isDropdownClick) {
setExpandedActions(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [expandedActions]);
// Ensure data is always an array
const safeData = Array.isArray(data) ? data : [];
@@ -81,7 +98,7 @@ export default function DataTable<T extends Record<string, any>>({
}
return (
<div className={cn('card p-0 overflow-visible', className)}>
<div className={cn('card p-0', className)}>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
@@ -145,66 +162,45 @@ export default function DataTable<T extends Record<string, any>>({
))}
{actions && actions.length > 0 && (
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="relative">
{(() => {
const visibleActions = actions.filter(action => !action.show || action.show(item));
if (visibleActions.length === 0) {
return null;
}
if (visibleActions.length === 1) {
const action = visibleActions[0];
return (
<ActionButton
onClick={() => action.onClick(item)}
variant={action.variant || 'secondary'}
size="sm"
icon={action.icon}
>
{action.label}
</ActionButton>
);
}
{(() => {
const visibleActions = actions.filter(action => !action.show || action.show(item));
if (visibleActions.length === 0) {
return null;
}
if (visibleActions.length === 1) {
const action = visibleActions[0];
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setExpandedActions(expandedActions === item.id ? null : item.id);
}}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{expandedActions === item.id && (
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<div className="py-1">
{visibleActions.map((action, actionIndex) => (
<button
key={actionIndex}
onClick={(e) => {
e.stopPropagation();
action.onClick(item);
setExpandedActions(null);
}}
className={cn(
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
)}
>
{action.icon && <action.icon className="h-4 w-4" />}
{action.label}
</button>
))}
</div>
</div>
)}
</>
<ActionButton
onClick={() => action.onClick(item)}
variant={action.variant || 'secondary'}
size="sm"
icon={action.icon}
>
{action.label}
</ActionButton>
);
})()}
</div>
}
return (
<button
ref={(el) => { buttonRefs.current[item.id] = el; }}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right + window.scrollX - 192, // 192px = w-48
});
setExpandedActions(expandedActions === item.id ? null : item.id);
}}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
);
})()}
</td>
)}
</tr>
@@ -218,6 +214,47 @@ export default function DataTable<T extends Record<string, any>>({
{emptyMessage}
</div>
)}
{expandedActions && dropdownPosition && typeof window !== 'undefined' && createPortal(
<div
data-dropdown-menu
style={{
position: 'absolute',
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
zIndex: 9999,
}}
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div className="py-1">
{actions
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
.map((action, actionIndex) => {
const item = sortedData.find(item => item.id === expandedActions);
if (!item) return null;
return (
<button
key={actionIndex}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setExpandedActions(null);
action.onClick(item);
}}
className={cn(
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
)}
>
{action.icon && <action.icon className="h-4 w-4" />}
{action.label}
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -111,6 +111,10 @@ export const schedulesApi = {
// Seats API
export const seatsApi = {
getSeatMap: (scheduleId: string, coachId?: string) => {
const params = coachId ? `?coachId=${coachId}` : '';
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
},
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),

View File

@@ -75,22 +75,22 @@ export default function ConfirmationPage() {
}
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
{/* Success Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center animate-bounce">
<CheckCircle className="w-12 h-12 text-green-600" />
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-4xl font-bold text-green-600 mb-2">Booking Confirmed!</h1>
<p className="text-gray-600 text-lg">Your train tickets are ready</p>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking Confirmed!</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
</div>
{/* PNR Card */}
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 text-white">
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white">
<div className="text-center">
<p className="text-sm opacity-90 mb-2">Booking Reference (PNR)</p>
<div className="flex items-center justify-center gap-3">
@@ -114,44 +114,44 @@ export default function ConfirmationPage() {
{/* Trip Summary */}
<div className="card mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary" />
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
</div>
<h2 className="text-2xl font-semibold">Trip Details</h2>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip Details</h2>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">Train Number</p>
<p className="font-semibold text-lg">{selectedSchedule?.trainNumber}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Train Number</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600">Route</p>
<p className="font-semibold text-lg">{selectedSchedule?.origin} {selectedSchedule?.destination}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</p>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div>
<p className="text-sm text-gray-600">Class</p>
<p className="font-semibold">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">Departure</p>
<p className="font-semibold">
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Arrival</p>
<p className="font-semibold">
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Duration</p>
<p className="font-semibold">{selectedSchedule?.duration}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule?.duration}</p>
</div>
</div>
</div>
@@ -159,7 +159,7 @@ export default function ConfirmationPage() {
{/* Tickets */}
<div className="mb-6">
<h2 className="text-2xl font-semibold mb-4">Your Tickets</h2>
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your Tickets</h2>
<div className="space-y-4">
{passengers.map((passenger, index) => {
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
@@ -178,47 +178,47 @@ export default function ConfirmationPage() {
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900">{passenger.name}</h3>
<p className="text-sm text-gray-600">Passenger {index + 1}</p>
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
</div>
<span className="badge badge-success">CONFIRMED</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600">Ticket Number</p>
<p className="font-semibold">{ticketNumber}</p>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
</div>
<div>
<p className="text-gray-600">Date of Birth</p>
<p className="font-semibold">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
</div>
<div>
<p className="text-gray-600">Nationality</p>
<p className="font-semibold">{passenger.nationality}</p>
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
</div>
<div>
<p className="text-gray-600">Seat</p>
<p className="font-semibold">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p>
<p className="text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p>
</div>
</div>
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs text-yellow-800">
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
📱 Show this QR code at the gate for boarding
</p>
</div>
</div>
{/* QR Code */}
<div className="flex flex-col items-center justify-center bg-gray-50 rounded-lg p-6">
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<QRCodeSVG
value={qrData}
size={160}
level="H"
includeMargin={true}
/>
<p className="text-xs text-gray-600 mt-2 text-center">Scan at gate</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center">Scan at gate</p>
</div>
</div>
</div>
@@ -266,13 +266,13 @@ export default function ConfirmationPage() {
{/* Info Notices */}
<div className="mt-6 space-y-3">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-300">
📧 A confirmation email with your tickets has been sent to your registered email address.
</p>
</div>
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-green-800">
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm text-green-800 dark:text-green-300">
Please arrive at the station at least 30 minutes before departure.
</p>
</div>

View File

@@ -5,19 +5,37 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
const passengerSchema = z.object({
name: z.string().min(2, 'Name is required'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.enum(['Male', 'Female']).optional(),
nationality: z.string().min(1, 'Nationality is required'),
phone: z.string().optional(),
email: z.string().email('Invalid email').optional().or(z.literal('')),
nationalId: z.string().optional(),
passportNumber: z.string().optional(),
passportCountry: z.string().optional(),
passportIssueDate: z.string().optional(),
passportExpiryDate: z.string().optional(),
passportIssuingAuthority: z.string().optional(),
faydaVerified: z.boolean().optional(),
faydaSub: z.string().optional(),
formExpanded: z.boolean().optional(),
}).refine((data) => {
// For non-Ethiopian passengers, passport number and country are required
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
return data.passportNumber && data.passportNumber.length > 0 &&
data.passportCountry && data.passportCountry.length > 0;
}
return true;
}, {
message: 'Passport number and country are required for non-Ethiopian passengers',
path: ['passportNumber'],
});
const formSchema = z.object({
@@ -30,8 +48,11 @@ type FormData = z.infer<typeof formSchema>;
export default function PassengersPage() {
const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
const [verifying, setVerifying] = useState<number | null>(null);
const { user, isAuthenticated, logout, updateUser } = useAuthStore();
const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [updatingUser, setUpdatingUser] = useState(false);
const [saving, setSaving] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -41,11 +62,18 @@ export default function PassengersPage() {
passengers: Array.from({ length: totalPassengers }, (_, i) => ({
name: '',
dateOfBirth: '',
gender: undefined,
nationality: searchCriteria?.nationality || 'ETHIOPIAN',
phone: '',
email: '',
nationalId: '',
passportNumber: '',
passportCountry: '',
passportIssueDate: '',
passportExpiryDate: '',
passportIssuingAuthority: '',
faydaVerified: false,
formExpanded: false,
})),
createAccount: false,
},
@@ -54,51 +82,131 @@ export default function PassengersPage() {
const { fields } = useFieldArray({ control, name: 'passengers' });
const passengers = watch('passengers');
const verifyFayda = async (index: number) => {
const nationalId = passengers[index].nationalId;
if (!nationalId) return;
setVerifying(index);
setVerificationStatus({ ...verificationStatus, [index]: undefined as any });
try {
const response: any = await apiClient.post('/passengers/verify-fayda', { nationalId });
if (response.verified && response.passengerData) {
setValue(`passengers.${index}.name`, response.passengerData.fullName);
setValue(`passengers.${index}.dateOfBirth`, response.passengerData.dateOfBirth.split('T')[0]);
setValue(`passengers.${index}.faydaVerified`, true);
setValue(`passengers.${index}.faydaSub`, response.passengerData.faydaSub);
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
} else {
setVerificationStatus({ ...verificationStatus, [index]: 'error' });
useEffect(() => {
const checkFaydaStatus = async () => {
try {
const response: any = await apiClient.get('/config/fayda-status');
setFaydaEnabled(response?.enabled ?? true);
} catch {
setFaydaEnabled(true);
}
};
checkFaydaStatus();
if (isAuthenticated && user && searchCriteria?.nationality === 'ETHIOPIAN') {
if (user.faydaVerified && user.fullName && user.dateOfBirth) {
setValue('passengers.0.name', user.fullName);
setValue('passengers.0.dateOfBirth', user.dateOfBirth);
setValue('passengers.0.gender', user.gender as any);
setValue('passengers.0.nationality', user.nationality || 'ETHIOPIAN');
setValue('passengers.0.phone', user.phone || '');
setValue('passengers.0.email', user.email || '');
setValue('passengers.0.faydaVerified', true);
setValue('passengers.0.faydaSub', user.faydaSub || '');
setValue('passengers.0.formExpanded', true);
setVerificationStatus({ 0: 'success' });
}
}
}, [isAuthenticated, user, searchCriteria, setValue]);
const openFaydaVerification = async (index: number) => {
const faydaUrl = process.env.NEXT_PUBLIC_FAYDA_URL || 'https://fayda.gov.et/verify';
const callbackUrl = `${window.location.origin}/booking/passengers?faydaCallback=${index}`;
const width = 600;
const height = 700;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
window.open(
`${faydaUrl}?callback=${encodeURIComponent(callbackUrl)}`,
'FaydaVerification',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
);
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type === 'FAYDA_VERIFIED' && event.data?.index === index) {
const data = event.data.passengerData;
setValue(`passengers.${index}.name`, data.fullName);
setValue(`passengers.${index}.dateOfBirth`, data.dateOfBirth.split('T')[0]);
setValue(`passengers.${index}.gender`, data.gender);
setValue(`passengers.${index}.nationality`, data.nationality || 'ETHIOPIAN');
setValue(`passengers.${index}.phone`, data.phone || '');
setValue(`passengers.${index}.email`, data.email || '');
setValue(`passengers.${index}.faydaVerified`, true);
setValue(`passengers.${index}.faydaSub`, data.faydaSub);
setValue(`passengers.${index}.formExpanded`, true);
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
if (index === 0 && isAuthenticated && user) {
setUpdatingUser(true);
try {
await apiClient.patch('/auth/profile', {
fullName: data.fullName,
dateOfBirth: data.dateOfBirth,
gender: data.gender,
nationality: data.nationality || 'ETHIOPIAN',
phone: data.phone,
faydaVerified: true,
faydaSub: data.faydaSub,
});
updateUser({
fullName: data.fullName,
dateOfBirth: data.dateOfBirth.split('T')[0],
gender: data.gender,
nationality: data.nationality || 'ETHIOPIAN',
phone: data.phone,
faydaVerified: true,
faydaSub: data.faydaSub,
});
} catch (error) {
console.error('Failed to update user profile:', error);
} finally {
setUpdatingUser(false);
}
}
window.removeEventListener('message', handleMessage);
}
};
window.addEventListener('message', handleMessage);
};
const toggleForm = (index: number) => {
setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded);
};
const onSubmit = async (data: FormData) => {
setSaving(true);
try {
const passengerDetails = data.passengers.map((p, i) => ({
...p,
isPrimaryPassenger: i === 0,
}));
// Save passenger details to database before proceeding
await apiClient.post('/passengers/save-details', {
passengers: passengerDetails,
userId: user?.id,
deviceId: localStorage.getItem('deviceId') || crypto.randomUUID(),
});
setPassengers(passengerDetails);
setCreateAccount(data.createAccount);
router.push('/booking/seats');
} catch (error) {
setVerificationStatus({ ...verificationStatus, [index]: 'error' });
console.error('Failed to save passenger details:', error);
alert('Failed to save passenger details. Please try again.');
} finally {
setVerifying(null);
setSaving(false);
}
};
const onSubmit = (data: FormData) => {
const passengerDetails = data.passengers.map((p, i) => ({
...p,
isPrimaryPassenger: i === 0,
}));
setPassengers(passengerDetails);
setCreateAccount(data.createAccount);
router.push('/booking/seats');
};
if (!searchCriteria) {
console.log('No search criteria, redirecting to search');
router.push('/booking/search');
return null;
}
console.log('Search criteria:', searchCriteria);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
@@ -108,8 +216,13 @@ export default function PassengersPage() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{fields.map((field, index) => {
const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN';
const isVerified = passengers[index]?.faydaVerified;
const isFormExpanded = passengers[index]?.formExpanded;
const status = verificationStatus[index];
const isPrimaryPassenger = index === 0;
const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified;
const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified;
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified;
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded;
return (
<div key={field.id} className="card">
@@ -121,117 +234,266 @@ export default function PassengersPage() {
</span>
</h3>
{showVerifyButton ? (
<div className="text-center py-8">
{isLoggedInNotVerified && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-300">
Please verify your identity with Fayda to complete your profile
</p>
</div>
)}
<button
type="button"
onClick={() => openFaydaVerification(index)}
className="btn-primary flex items-center justify-center gap-2 mx-auto"
disabled={updatingUser}
>
{updatingUser ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<ExternalLink className="w-5 h-5" />
)}
{updatingUser ? 'Updating Profile...' : 'Verify with Fayda'}
</button>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3">
Click to verify your Ethiopian national ID
</p>
{!isLoggedInNotVerified && (
<button
type="button"
onClick={() => toggleForm(index)}
className="text-sm text-primary hover:underline mt-2"
>
Or enter details manually
</button>
)}
</div>
) : showManualEntryLink ? (
<div className="text-center py-8">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Fayda verification is currently unavailable
</p>
<button
type="button"
onClick={() => toggleForm(index)}
className="btn-primary"
>
Enter Details Manually
</button>
</div>
) : (
<div className="space-y-4">
{isEthiopian ? (
<>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">National ID</label>
<div className="flex gap-2">
<input
{...register(`passengers.${index}.nationalId`)}
className="input-field"
placeholder="ET123456789"
disabled={isVerified}
/>
<button
type="button"
onClick={() => verifyFayda(index)}
disabled={verifying === index || isVerified}
className="btn-primary whitespace-nowrap"
>
{verifying === index ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isVerified ? (
<CheckCircle className="w-4 h-4" />
) : (
'Verify'
)}
</button>
{status === 'success' && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> Verified with Fayda
</p>
</div>
{status === 'success' && (
<p className="text-green-600 dark:text-green-400 text-sm mt-1 flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Verified successfully
</p>
)}
{status === 'error' && (
<p className="text-red-600 dark:text-red-400 text-sm mt-1 flex items-center gap-1">
<XCircle className="w-4 h-4" /> Verification failed. You can continue manually.
</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
disabled={isVerified}
placeholder="Full name as per ID"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per ID"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
disabled={isVerified}
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<select
{...register(`passengers.${index}.gender`)}
className="input-field"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input
{...register(`passengers.${index}.phone`)}
className="input-field"
placeholder="+251911234567"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className="input-field"
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div>
</>
) : (
<>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per passport"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className="input-field"
placeholder="Full name as per passport"
/>
{errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<select
{...register(`passengers.${index}.gender`)}
className="input-field"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
<input
{...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly
disabled
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
<input
{...register(`passengers.${index}.phone`)}
className="input-field"
placeholder="+254712345678"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register(`passengers.${index}.email`)}
className="input-field"
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
<input
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
/>
{errors.passengers?.[index]?.dateOfBirth && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div>
<div className="border-t dark:border-gray-700 pt-4 mt-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
<input
{...register(`passengers.${index}.passportNumber`)}
className="input-field"
placeholder="P1234567"
/>
{errors.passengers?.[index]?.passportNumber && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number</label>
<input
{...register(`passengers.${index}.passportNumber`)}
className="input-field"
placeholder="P1234567"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country *</label>
<input
{...register(`passengers.${index}.passportCountry`)}
className="input-field"
placeholder="Djibouti"
/>
{errors.passengers?.[index]?.passportCountry && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country</label>
<input
{...register(`passengers.${index}.passportCountry`)}
className="input-field"
placeholder="Kenya"
/>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Authority</label>
<input
{...register(`passengers.${index}.passportIssuingAuthority`)}
className="input-field"
placeholder="Government of Djibouti"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issue Date</label>
<input
type="date"
{...register(`passengers.${index}.passportIssueDate`)}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Expiry Date</label>
<input
type="date"
{...register(`passengers.${index}.passportExpiryDate`)}
className="input-field"
/>
</div>
</div>
</div>
</>
)}
</div>
)}
</div>
);
})}
@@ -244,11 +506,11 @@ export default function PassengersPage() {
</div>
<div className="flex gap-4">
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1">
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
Back
</button>
<button type="submit" className="btn-primary flex-1">
Continue to Seat Selection
<button type="submit" className="btn-primary flex-1" disabled={saving}>
{saving ? 'Saving...' : 'Continue to Seat Selection'}
</button>
</div>
</form>

View File

@@ -5,7 +5,7 @@ import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react';
// Mock payment methods with Ethiopian providers
@@ -132,36 +132,56 @@ export default function PaymentPage() {
});
};
if (!bookingId || !pnr) {
router.push('/booking/search');
return null;
// Redirect if no booking data (but not during navigation)
useEffect(() => {
// Add a small delay to allow state to be set from previous page
const timer = setTimeout(() => {
if (!bookingId || !pnr) {
console.log('Payment page: Missing booking data, redirecting to search');
console.log('bookingId:', bookingId, 'pnr:', pnr);
router.push('/booking/search');
}
}, 500);
return () => clearTimeout(timer);
}, [bookingId, pnr, router]);
if (!bookingId && !pnr) {
return (
<div className="min-h-screen bg-gray-50 py-12 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">Loading payment details...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Complete Payment</h1>
<p className="text-gray-600 mb-6">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete Payment</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Booking Reference: <span className="font-bold text-primary">{pnr}</span>
</p>
{/* Payment Processing Overlay */}
{isProcessing && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-md text-center">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md text-center">
{paymentMutation.isSuccess ? (
<>
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Payment Successful!</h3>
<p className="text-gray-600 mb-4">Generating your tickets...</p>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment Successful!</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
</>
) : (
<>
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Processing Payment</h3>
<p className="text-gray-600">Please wait while we process your payment...</p>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing Payment</h3>
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
</>
)}
</div>
@@ -170,29 +190,29 @@ export default function PaymentPage() {
{/* Order Summary */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4">Order Summary</h2>
<h2 className="text-xl font-semibold mb-4 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">Route</span>
<span className="font-medium">{selectedSchedule?.origin} {selectedSchedule?.destination}</span>
<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">Train</span>
<span className="font-medium">{selectedSchedule?.trainNumber}</span>
<span className="text-gray-600 dark:text-gray-400">Train</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</span>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div className="flex justify-between">
<span className="text-gray-600">Class</span>
<span className="font-medium">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
<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, ' ')}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600">Passengers</span>
<span className="font-medium">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
<span className="text-gray-600 dark:text-gray-400">Passengers</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
</div>
<div className="border-t pt-3 mt-3">
<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>Total Amount</span>
<span className="text-gray-900 dark:text-gray-100">Total Amount</span>
<span className="text-primary">
ETB {(totalAmount / 100).toFixed(2)}
</span>
@@ -203,7 +223,7 @@ export default function PaymentPage() {
{/* Payment Methods */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select Payment Method</h2>
<div className="space-y-3">
{paymentMethods.map((method) => {
const Icon = method.icon;
@@ -215,19 +235,19 @@ export default function PaymentPage() {
disabled={isProcessing}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? 'border-primary bg-primary-50 shadow-md'
: method.color
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-md'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary'
} ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected ? 'bg-primary' : 'bg-white'
isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'
}`}>
<Icon className={`w-6 h-6 ${isSelected ? 'text-white' : 'text-primary'}`} />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900">{method.name}</p>
<p className="text-sm text-gray-600">{method.description}</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{method.description}</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
@@ -271,16 +291,16 @@ export default function PaymentPage() {
{/* Error Message */}
{paymentMutation.isError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<p className="text-red-800 text-sm font-medium">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
Payment failed. Please try again or contact support if the problem persists.
</p>
</div>
)}
{/* Security Notice */}
<div className="mt-6 p-4 bg-gray-100 rounded-lg">
<p className="text-xs text-gray-600 text-center">
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
🔒 Your payment is secure and encrypted. We do not store your payment information.
</p>
</div>

View File

@@ -40,7 +40,10 @@ export default function ResultsPage() {
const { data: results, isLoading, error } = useQuery<Schedule[]>({
queryKey: ['search', searchData],
queryFn: async () => {
console.log('Searching with criteria:', searchData);
const response = await apiClient.post('/search', searchData);
console.log('Search results:', response);
console.log('Number of results:', response?.length || 0);
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
@@ -232,7 +235,7 @@ export default function ResultsPage() {
{schedule.stops && schedule.stops.length > 0 && (
<>
<MapPin className="w-4 h-4" />
<span>{schedule.stops.length} stops</span>
<span>{schedule.stops.length - 2} stops</span>
</>
)}
</div>

View File

@@ -11,6 +11,7 @@ export default function ReviewPage() {
const router = useRouter();
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount } = useBookingStore();
const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
useEffect(() => {
if (!seatHold?.expiresAt) return;
@@ -33,22 +34,70 @@ export default function ReviewPage() {
return () => clearInterval(interval);
}, [seatHold]);
useEffect(() => {
const fetchSeatDetails = async () => {
if (!selectedSchedule?.id) return;
try {
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
const coaches = seatMapData?.coaches || [];
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
const details: Record<string, string> = {};
passengers.forEach(p => {
if (p.seatId) {
const seat = allSeats.find((s: any) => s.id === p.seatId);
if (seat) {
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
}
}
});
setSeatDetails(details);
} catch (error) {
console.error('Failed to fetch seat details:', error);
}
};
fetchSeatDetails();
}, [selectedSchedule?.id, passengers]);
const createBookingMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/bookings/guest', data),
onSuccess: (data: any) => {
setBookingId(data.bookingId || data.id);
setPNR(data.pnr || data.bookingReference);
console.log('Booking created successfully:', data);
const bookingIdValue = data.bookingId || data.id;
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
console.log('Setting booking ID:', bookingIdValue);
console.log('Setting PNR:', pnrValue);
setBookingId(bookingIdValue);
setPNR(pnrValue);
// Check if payment is required
const totalAmount = data.totalMinor || data.totalAmount || 0;
if (totalAmount > 0) {
// Redirect to payment page
router.push('/booking/payment');
} else {
// No payment required, go directly to confirmation
router.push('/booking/confirmation');
}
console.log('Total amount:', totalAmount);
console.log('Booking store after update:', useBookingStore.getState());
// Use setTimeout to ensure state updates complete before navigation
setTimeout(() => {
// Verify state was set
const currentState = useBookingStore.getState();
console.log('Current booking store state:', currentState);
console.log('bookingId:', currentState.bookingId);
console.log('pnr:', currentState.pnr);
if (totalAmount > 0) {
// Redirect to payment page
console.log('Redirecting to payment page');
router.push('/booking/payment');
} else {
// No payment required, go directly to confirmation
console.log('Redirecting to confirmation page');
router.push('/booking/confirmation');
}
}, 100);
},
onError: (error: any) => {
console.error('Booking creation failed:', error);
@@ -58,11 +107,18 @@ export default function ReviewPage() {
});
const handleConfirm = async () => {
console.log('handleConfirm called');
try {
const { searchCriteria } = useBookingStore.getState();
console.log('Search criteria:', searchCriteria);
console.log('Seat hold:', seatHold);
console.log('Selected schedule:', selectedSchedule);
console.log('Passengers:', passengers);
// Validate that we have a hold
if (!seatHold?.holdId) {
console.error('No seat hold found');
alert('Please select seats before continuing.');
router.push('/booking/seats');
return;
@@ -70,6 +126,7 @@ export default function ReviewPage() {
// Validate search criteria
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
console.error('Missing search criteria');
alert('Missing search criteria. Please start over.');
router.push('/booking/search');
return;
@@ -79,6 +136,7 @@ export default function ReviewPage() {
let seatClassId = '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;
}
@@ -93,17 +151,24 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
displayCurrency: 'ETB' as const,
passengers: passengers.map(p => ({
seatId: p.seatId || '',
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: p.nationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
idDocumentNumber: p.nationalId || p.passportNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
nationality: p.nationality,
})),
createAccount: createAccount,
passengers: passengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const hasNationalId = isEthiopian && p.nationalId;
return {
seatId: p.seatId || '',
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: hasNationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
idDocumentNumber: p.nationalId || undefined,
passportNumber: !hasNationalId ? p.passportNumber : undefined,
passportCountry: !hasNationalId ? p.passportCountry : undefined,
nationality: p.nationality,
phone: p.phone,
email: p.email,
};
}),
createAccount: createAccount || false,
savePassengerDetails: true,
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
};
@@ -114,37 +179,61 @@ export default function ReviewPage() {
}
console.log('Creating booking with payload:', bookingData);
createBookingMutation.mutate(bookingData);
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
console.error('Error in handleConfirm:', error);
alert('An unexpected error occurred. Please try again.');
}
};
if (!selectedSchedule || !passengers.length) {
if (typeof window !== 'undefined') {
router.push('/booking/search');
// Only redirect to search if we're not in the middle of creating a booking
useEffect(() => {
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]);
if (!selectedSchedule || !passengers.length) {
return null;
}
// Debug: Log selected schedule data
console.log('Selected schedule:', selectedSchedule);
console.log('Base fare adult:', selectedSchedule.baseFareAdult);
console.log('Passengers:', passengers);
// Calculate fare - use the fare from selected schedule or from fare breakdown
const baseFare = passengers.reduce((sum, p, i) => {
const isChild = i >= (passengers.length - (passengers.filter(p => p.dateOfBirth).length));
const isFreeChild = isChild && i === passengers.length - 1;
return sum + (isFreeChild ? 0 : selectedSchedule.baseFareAdult);
// Get the fare per passenger from the schedule
const farePerPassenger = selectedSchedule.baseFareAdult ||
selectedSchedule.baseFare ||
selectedSchedule.fareAdult ||
selectedSchedule.price ||
0;
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
// For now, charge all passengers the same fare
// TODO: Implement proper age-based pricing when we have dateOfBirth
return sum + farePerPassenger;
}, 0);
console.log('Calculated base fare:', baseFare);
const total = baseFare;
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Review Your Booking</h1>
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review Your Booking</h1>
{seatHold && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<p className="text-yellow-800 dark:text-yellow-200">
Your seats will be released in: <span className="font-bold">{timeLeft}</span>
</p>
</div>
@@ -152,49 +241,49 @@ export default function ReviewPage() {
<div className="space-y-6">
<div className="card">
<h2 className="text-xl font-semibold mb-4">Trip Details</h2>
<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">Train</span>
<span className="font-medium">{selectedSchedule.trainNumber}</span>
<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">Route</span>
<span className="font-medium">{selectedSchedule.origin} {selectedSchedule.destination}</span>
<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">Departure</span>
<span className="font-medium">
<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'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Arrival</span>
<span className="font-medium">
<span className="text-gray-600 dark:text-gray-400">Arrival</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Duration</span>
<span className="font-medium">{selectedSchedule.duration}</span>
<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>
</div>
</div>
</div>
<div className="card">
<h2 className="text-xl font-semibold mb-4">Passengers</h2>
<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 pb-2 last:border-0">
<div key={i} className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
<div>
<p className="font-medium">{p.name}</p>
<p className="text-sm text-gray-600">
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} {p.nationality}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">Seat</p>
<p className="font-medium">{p.seatId ? 'Selected' : 'Auto-assign'}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
</div>
</div>
))}
@@ -202,15 +291,15 @@ export default function ReviewPage() {
</div>
<div className="card">
<h2 className="text-xl font-semibold mb-4">Fare Breakdown</h2>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare Breakdown</h2>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Base Fare</span>
<span>ETB {(baseFare / 100).toFixed(2)}</span>
<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 pt-2">
<span>Total</span>
<span className="text-primary">ETB {(total / 100).toFixed(2)}</span>
<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-gray-900 dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
</div>
</div>
</div>
@@ -229,8 +318,8 @@ export default function ReviewPage() {
</div>
{createBookingMutation.isError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<p className="text-red-800 text-sm">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
<p className="text-red-800 dark:text-red-200 text-sm">
{createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'}
</p>
</div>

View File

@@ -154,7 +154,7 @@ export default function SearchPage() {
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"
disabled={isLoading}
>
<option value="">Select departure</option>
<option value="">Select departure station</option>
{stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
@@ -183,7 +183,7 @@ export default function SearchPage() {
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"
disabled={isLoading}
>
<option value="">Select arrival</option>
<option value="">Select arrival station</option>
{stations?.map((s) => (
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
))}
@@ -251,7 +251,7 @@ export default function SearchPage() {
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-600">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-primary-600 dark:text-primary-400">&lt;5 years First child free</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First child free</div>
</div>
<div className="flex items-center gap-3">
<button

View File

@@ -21,12 +21,21 @@ export default function SeatsPage() {
type: 'info' as 'warning' | 'error' | 'success' | 'info',
});
const { data: seatMapData } = useQuery({
const { data: seatMapData, isLoading, error } = useQuery({
queryKey: ['seatmap', selectedSchedule?.id],
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
enabled: !!selectedSchedule?.id,
});
// Debug: Log the seat map data
useEffect(() => {
if (seatMapData) {
console.log('Seat map data:', seatMapData);
console.log('Is array?', Array.isArray(seatMapData));
console.log('Has coaches?', seatMapData?.coaches);
}
}, [seatMapData]);
const holdMutation = useMutation({
mutationFn: async (seatIds: string[]) => {
// Create temporary passenger IDs for the hold
@@ -51,16 +60,47 @@ export default function SeatsPage() {
});
// Extract coaches and seats from seat map data
const coaches = Array.isArray(seatMapData) ? seatMapData : (seatMapData?.coaches || []);
const coaches = seatMapData?.coaches || [];
// Debug: Log coaches
useEffect(() => {
console.log('Coaches:', coaches);
console.log('Selected seat class:', selectedSchedule?.selectedSeatClass);
if (coaches.length > 0) {
console.log('First coach structure:', coaches[0]);
console.log('First coach seatClass:', coaches[0]?.seatClass);
console.log('First coach coachClass:', coaches[0]?.coachClass);
}
}, [coaches, selectedSchedule?.selectedSeatClass]);
// Filter coaches by selected seat class if available
const filteredCoaches = selectedSchedule?.selectedSeatClass
? coaches.filter((c: any) => c.seatClass?.name === selectedSchedule.selectedSeatClass || c.coachClass === selectedSchedule.selectedSeatClass)
? coaches.filter((c: any) => {
// seatClass can be either a string or an object with a name property
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
console.log('Comparing:', seatClassName, 'with', selectedSchedule.selectedSeatClass);
return seatClassName === selectedSchedule.selectedSeatClass ||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass.toLowerCase() ||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass.toLowerCase();
})
: coaches;
// Debug filtered coaches
useEffect(() => {
console.log('Filtered coaches:', filteredCoaches);
console.log('Filtered coaches count:', filteredCoaches.length);
}, [filteredCoaches]);
const selectedCoachData = filteredCoaches.find((c: any) => c.id === selectedCoach);
const seats = selectedCoachData?.seats || [];
// Debug seats
useEffect(() => {
console.log('Selected coach data:', selectedCoachData);
console.log('Seats:', seats);
console.log('Seats count:', seats.length);
}, [selectedCoachData, seats]);
useEffect(() => {
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
setSelectedCoach(filteredCoaches[0].id);
@@ -143,15 +183,16 @@ export default function SeatsPage() {
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="card mb-4">
<h3 className="font-semibold mb-3">Select Coach</h3>
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select Coach</h3>
{selectedSchedule?.selectedSeatClassName && (
<div className="mb-3 text-sm text-gray-600">
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-2">
{filteredCoaches?.map((coach: any) => {
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
return (
<button
key={coach.id}
@@ -159,11 +200,11 @@ export default function SeatsPage() {
className={`px-4 py-2 rounded whitespace-nowrap ${
selectedCoach === coach.id
? 'bg-primary text-white'
: 'bg-gray-200 hover:bg-gray-300'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
<div>{coach.label || coach.coachNumber}</div>
<div className="text-xs opacity-75">{coach.seatClass?.name || coach.coachClass}</div>
<div>{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button>
);
@@ -172,16 +213,25 @@ export default function SeatsPage() {
</div>
<div className="card">
<h3 className="font-semibold mb-4">Seat Map - {selectedCoachData?.name || selectedCoachData?.label}</h3>
{seats.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat Map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
{isLoading ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Loading seats...</p>
</div>
) : error ? (
<div className="text-center py-8 text-red-500 dark:text-red-400">
<p>Error loading seats</p>
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
</div>
) : seats.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p>
<p className="text-sm mt-2">Please select a different coach</p>
</div>
) : (
<>
{/* Seat Grid */}
<div className="bg-gray-50 p-4 rounded-lg mb-4 overflow-x-auto">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto">
<div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}>
{seats?.map((seat: any) => {
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
@@ -194,10 +244,10 @@ export default function SeatsPage() {
selectedSeats.includes(seat.id)
? 'bg-primary text-white shadow-md scale-105'
: seat.status === 'AVAILABLE'
? 'bg-green-100 hover:bg-green-200 text-green-800 hover:shadow-md'
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md'
: seat.status === 'HELD'
? 'bg-yellow-100 text-yellow-700 cursor-not-allowed opacity-75'
: 'bg-gray-200 text-gray-500 cursor-not-allowed opacity-60'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
}`}
title={`Seat ${seatLabel} - ${seat.status}`}
>
@@ -209,9 +259,9 @@ export default function SeatsPage() {
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-100 rounded"></div>
<div className="w-6 h-6 bg-green-100 dark:bg-green-900/40 rounded"></div>
<span>Available</span>
</div>
<div className="flex items-center gap-2">
@@ -219,11 +269,11 @@ export default function SeatsPage() {
<span>Selected</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-yellow-100 rounded"></div>
<div className="w-6 h-6 bg-yellow-100 dark:bg-yellow-900/40 rounded"></div>
<span>Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gray-200 rounded"></div>
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded"></div>
<span>Booked</span>
</div>
</div>
@@ -234,11 +284,11 @@ export default function SeatsPage() {
<div>
<div className="card sticky top-4">
<h3 className="font-semibold mb-4">Selection Summary</h3>
<p className="text-sm text-gray-600 mb-4">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection Summary</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select {passengers.length} seat(s) for your passengers
</p>
<p className="text-lg font-semibold mb-4">
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{selectedSeats.length} / {passengers.length} selected
</p>
@@ -247,7 +297,7 @@ export default function SeatsPage() {
const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null;
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
return (
<div key={i} className="flex justify-between text-sm">
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
<span>{p.name}</span>
<span className="font-medium">
{seatLabel}

View File

@@ -0,0 +1,245 @@
'use client';
import { ArrowLeft, Search, Users, CreditCard, Ticket, CheckCircle, Train, Calendar, MapPin } from 'lucide-react';
import Link from 'next/link';
export default function HowToGuidePage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<Link
href="/booking/search"
className="inline-flex items-center gap-2 text-primary hover:underline mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Search
</Link>
<div className="card mb-8">
<div className="flex items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">How to Book Your Train Ticket</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Follow these simple steps to book your journey
</p>
</div>
</div>
{/* Booking Steps */}
<div className="space-y-8">
{/* Step 1 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Search className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">1. Search for Trains</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
Select your origin and destination stations, choose your travel date, and specify the number of passengers.
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-primary" />
<span className="text-gray-700 dark:text-gray-300">Select departure and arrival stations</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-primary" />
<span className="text-gray-700 dark:text-gray-300">Choose your travel date (today or future)</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-primary" />
<span className="text-gray-700 dark:text-gray-300">Specify adults and children (under 5 years)</span>
</div>
</div>
</div>
</div>
{/* Step 2 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Train className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">2. Select Your Train & Class</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
Browse available trains, compare prices, and select your preferred seat class.
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Available Classes:</p>
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-300">
<li> <strong>Economy Regular</strong> - Standard seating</li>
<li> <strong>Economy Bed</strong> - Sleeper berths</li>
<li> <strong>VIP Bed</strong> - Premium sleeper cabins</li>
</ul>
</div>
</div>
</div>
{/* Step 3 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter Passenger Details</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
You can sign in for a faster experience or continue as a guest.
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">For Ethiopian Citizens:</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Click "Verify with Fayda" to auto-fill your details using your national ID.</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">For International Travelers:</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Enter your passport details and personal information manually.</p>
</div>
</div>
</div>
</div>
{/* Step 4 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Ticket className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select Seats</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
Choose your preferred seats from the interactive seat map. Available seats are shown in green.
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-yellow-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Selected</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gray-400 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Booked</span>
</div>
</div>
</div>
</div>
</div>
{/* Step 5 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<CreditCard className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">5. Review & Pay</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
Review your booking details and complete the payment.
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Payment Methods:</p>
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-300">
<li> Telebirr</li>
<li> CBE Birr</li>
<li> Credit/Debit Card</li>
<li> E-Wallet</li>
</ul>
</div>
</div>
</div>
{/* Step 6 */}
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">6. Get Your Tickets</h3>
<p className="text-gray-600 dark:text-gray-400 mb-3">
Your tickets will be displayed with QR codes. Save or print them for boarding.
</p>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4 rounded-lg">
<p className="text-sm text-green-800 dark:text-green-300">
📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
</p>
</div>
</div>
</div>
</div>
</div>
{/* FAQs */}
<div className="card">
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-gray-100">Frequently Asked Questions</h2>
<div className="space-y-6">
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Can I book tickets without an account?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Yes! You can book as a guest. However, creating an account allows you to save passenger details and view booking history.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What is Fayda verification?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fayda is the Ethiopian national ID verification system. It allows Ethiopian citizens to quickly verify their identity and auto-fill their information.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">How does age-based pricing work?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Adults (5+ years) pay full fare. Children under 5 years travel free for the first child; additional children pay full fare.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Can I change or cancel my booking?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Yes, you can modify or cancel your booking through your account. Cancellation policies apply.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Which currencies are supported?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
All transactions are in ETB (Ethiopian Birr). You can view prices in DJF (Djiboutian Franc) or USD for reference.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What should I bring on the day of travel?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
</p>
</div>
</div>
</div>
{/* CTA */}
<div className="text-center mt-8">
<Link href="/booking/search" className="btn-primary inline-flex items-center gap-2">
<Search className="w-5 h-5" />
Start Booking Now
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
import { useState } from 'react';
import { Train } from 'lucide-react';
@@ -19,6 +20,7 @@ export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
const { searchCriteria } = useBookingStore();
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -41,26 +43,26 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Train className="w-12 h-12 text-primary" />
</div>
<h1 className="text-3xl font-bold">Sign In</h1>
<p className="text-gray-600 mt-2">Welcome back to EDR Platform</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign In</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back to EDR Platform</p>
</div>
<div className="card">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
{...register('email')}
@@ -73,7 +75,7 @@ export default function LoginPage() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
<input
type="password"
{...register('password')}
@@ -92,8 +94,13 @@ export default function LoginPage() {
<div className="mt-6 text-center">
<button
onClick={() => router.push('/booking/auth-check')}
className="text-sm text-gray-600 hover:text-primary"
onClick={() => {
// If booking is started (search criteria exists), go back to passengers page
// Otherwise, go to booking search page
const destination = searchCriteria ? '/booking/passengers' : '/booking/search';
router.push(destination);
}}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
>
Back to booking
</button>

View File

@@ -0,0 +1,818 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider';
import {
User, Settings, Ticket, ChevronRight, Calendar, MapPin,
Download, Trash2, Lock, Bell, CreditCard, Globe,
MapPinned, Palette, CheckCircle, XCircle, Clock,
Eye, Edit, LogOut, X
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { useQuery, useMutation } from '@tanstack/react-query';
import CustomModal from '@/components/CustomModal';
type Tab = 'bookings' | 'profile' | 'settings';
interface Booking {
id: string;
pnr: string;
status: string;
totalMinor: number;
createdAt: string;
trip?: {
trainNumber: string;
departureAt: string;
origin?: { name: string };
destination?: { name: string };
};
}
export default function ProfilePage() {
const router = useRouter();
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
const { theme, setTheme } = useTheme();
const [activeTab, setActiveTab] = useState<Tab>('bookings');
const [showModal, setShowModal] = useState(false);
const [modalConfig, setModalConfig] = useState<any>({});
const [showEditProfile, setShowEditProfile] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [editForm, setEditForm] = useState({
fullName: '',
phone: '',
email: '',
dateOfBirth: '',
gender: '',
nationality: '',
});
const [settings, setSettings] = useState({
notifications: true,
emailNotifications: true,
smsNotifications: false,
preferredOrigin: '',
preferredPaymentMethod: 'TELEBIRR',
preferredCurrency: 'ETB',
preferredLanguage: 'en',
});
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
if (!isInitialized) return;
if (isAuthenticated && user) {
// Fetch fresh profile data
fetchProfile().catch(() => {
// If fetch fails, redirect to login
router.push('/login?redirect=/profile');
});
setEditForm({
fullName: user.fullName || '',
phone: user.phone || '',
email: user.email || '',
dateOfBirth: user.dateOfBirth || '',
gender: user.gender || '',
nationality: user.nationality || '',
});
} else if (!isAuthenticated) {
router.push('/login?redirect=/profile');
}
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
const { data: bookings, isLoading: loadingBookings } = useQuery({
queryKey: ['user-bookings'],
queryFn: async () => {
try {
return await apiClient.get('/bookings/my-bookings');
} catch {
return [];
}
},
enabled: isAuthenticated && activeTab === 'bookings',
});
const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
onSuccess: (response) => {
const updatedData = response.data || response;
updateUser(updatedData);
setShowEditProfile(false);
setModalConfig({
type: 'success',
title: 'Profile Updated',
message: 'Your profile has been updated successfully.',
});
setShowModal(true);
},
onError: (error: any) => {
setModalConfig({
type: 'error',
title: 'Error',
message: error.response?.data?.message || 'Failed to update profile. Please try again.',
});
setShowModal(true);
},
});
const changePasswordMutation = useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
apiClient.patch('/auth/change-password', data),
onSuccess: () => {
setShowChangePassword(false);
setModalConfig({
type: 'success',
title: 'Password Changed',
message: 'Your password has been updated successfully.',
});
setShowModal(true);
},
onError: (error: any) => {
setModalConfig({
type: 'error',
title: 'Error',
message: error.response?.data?.message || 'Failed to change password. Please check your current password.',
});
setShowModal(true);
},
});
const downloadDataMutation = useMutation({
mutationFn: () => apiClient.get('/auth/download-data'),
onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `edr-data-${new Date().toISOString()}.json`;
a.click();
setModalConfig({
type: 'success',
title: 'Data Downloaded',
message: 'Your data has been downloaded successfully.',
});
setShowModal(true);
},
});
const deleteAccountMutation = useMutation({
mutationFn: () => apiClient.delete('/auth/account'),
onSuccess: () => {
logout();
router.push('/booking/search');
},
});
const handleEditProfile = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
updateProfileMutation.mutate(editForm);
};
const handleChangePassword = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (newPassword !== confirmPassword) {
setModalConfig({
type: 'error',
title: 'Password Mismatch',
message: 'New passwords do not match.',
});
setShowModal(true);
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
e.currentTarget.reset();
};
const handleLogout = () => {
setModalConfig({
type: 'warning',
title: 'Sign Out',
message: 'Are you sure you want to sign out?',
onConfirm: async () => {
await logout();
// Navigation will be handled by logout function
},
});
setShowModal(true);
};
const handleDownloadData = () => {
setModalConfig({
type: 'warning',
title: 'Download Your Data',
message: 'This will download all your personal data in JSON format. Continue?',
onConfirm: () => {
downloadDataMutation.mutate();
setShowModal(false);
},
});
setShowModal(true);
};
const handleDeleteAccount = () => {
setModalConfig({
type: 'error',
title: 'Delete Account',
message: 'This action cannot be undone. All your data will be permanently deleted. Are you sure?',
onConfirm: () => {
deleteAccountMutation.mutate();
setShowModal(false);
},
});
setShowModal(true);
};
const getStatusBadge = (status: string) => {
const styles = {
CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
};
return styles[status as keyof typeof styles] || styles.PENDING;
};
if (!isInitialized || !user) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="card mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{user.fullName}</h1>
<p className="text-gray-600 dark:text-gray-400">{user.email}</p>
{user.faydaVerified && (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 mt-1">
<CheckCircle className="w-3 h-3" />
Verified with Fayda
</span>
)}
</div>
</div>
<button
onClick={handleLogout}
className="btn-secondary flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
</div>
{/* Tabs */}
<div className="card mb-6">
<div className="flex gap-2 border-b dark:border-gray-700 pb-2">
<button
onClick={() => setActiveTab('bookings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'bookings'
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Ticket className="w-4 h-4" />
Bookings
</button>
<button
onClick={() => setActiveTab('profile')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'profile'
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<User className="w-4 h-4" />
Profile
</button>
<button
onClick={() => setActiveTab('settings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'settings'
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Settings className="w-4 h-4" />
Settings
</button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'bookings' && (
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
{loadingBookings ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
</div>
) : bookings && bookings.length > 0 ? (
bookings.map((booking: Booking) => (
<div key={booking.id} className="card hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className={`badge ${getStatusBadge(booking.status)}`}>
{booking.status}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
PNR: {booking.pnr}
</span>
</div>
<div className="grid md:grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.departureAt
? new Date(booking.trip.departureAt).toLocaleDateString()
: 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.origin?.name} {booking.trip?.destination?.name}
</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-gray-100 font-semibold">
ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => router.push(`/booking/confirmation?id=${booking.id}`)}
className="btn-secondary text-sm flex items-center gap-2"
>
<Eye className="w-4 h-4" />
View
</button>
</div>
</div>
</div>
))
) : (
<div className="card text-center py-12">
<Ticket className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">No bookings yet</p>
<button onClick={() => router.push('/booking/search')} className="btn-primary">
Book Your First Trip
</button>
</div>
)}
</div>
)}
{activeTab === 'profile' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Profile Information</h2>
<div className="card">
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.fullName}</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">
{user.dateOfBirth ? new Date(user.dateOfBirth).toLocaleDateString() : 'Not set'}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.gender || 'Not set'}</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.nationality || 'Not set'}</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.email}</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone</label>
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.phone || 'Not set'}</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowEditProfile(true)}
className="btn-secondary flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit Profile
</button>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Settings</h2>
{/* Appearance */}
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
<Palette className="w-5 h-5" />
Appearance
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Theme</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as any)}
className="input-field"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
</div>
</div>
{/* Notifications */}
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
<Bell className="w-5 h-5" />
Notifications
</h3>
<div className="space-y-4">
<label className="flex items-center justify-between cursor-pointer group">
<span className="text-sm text-gray-700 dark:text-gray-300">Push Notifications</span>
<div className="relative inline-block w-12 h-6">
<input
type="checkbox"
checked={settings.notifications}
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>
<label className="flex items-center justify-between cursor-pointer group">
<span className="text-sm text-gray-700 dark:text-gray-300">Email Notifications</span>
<div className="relative inline-block w-12 h-6">
<input
type="checkbox"
checked={settings.emailNotifications}
onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>
<label className="flex items-center justify-between cursor-pointer group">
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Notifications</span>
<div className="relative inline-block w-12 h-6">
<input
type="checkbox"
checked={settings.smsNotifications}
onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>
</div>
</div>
{/* Preferences */}
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
<MapPinned className="w-5 h-5" />
Preferences
</h3>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Origin</label>
<input
type="text"
value={settings.preferredOrigin}
onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })}
className="input-field"
placeholder="e.g., Addis Ababa"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Payment Method</label>
<select
value={settings.preferredPaymentMethod}
onChange={(e) => setSettings({ ...settings, preferredPaymentMethod: e.target.value })}
className="input-field"
>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="CARD">Credit/Debit Card</option>
<option value="WALLET">E-Wallet</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Currency</label>
<select
value={settings.preferredCurrency}
onChange={(e) => setSettings({ ...settings, preferredCurrency: e.target.value })}
className="input-field"
>
<option value="ETB">ETB (Ethiopian Birr)</option>
<option value="DJF">DJF (Djiboutian Franc)</option>
<option value="USD">USD (US Dollar)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Language</label>
<select
value={settings.preferredLanguage}
onChange={(e) => setSettings({ ...settings, preferredLanguage: e.target.value })}
className="input-field"
>
<option value="en">English</option>
<option value="am"> (Amharic)</option>
<option value="fr">Français (French)</option>
</select>
</div>
</div>
</div>
{/* Security */}
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
<Lock className="w-5 h-5" />
Security
</h3>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Password</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Change your account password</p>
</div>
<button
onClick={() => setShowChangePassword(true)}
className="btn-secondary flex items-center gap-2"
>
<Lock className="w-4 h-4" />
Change Password
</button>
</div>
</div>
{/* Data & Privacy */}
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
<Download className="w-5 h-5" />
Data & Privacy
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Download Your Data</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Get a copy of all your data</p>
</div>
<button
onClick={handleDownloadData}
className="btn-secondary flex items-center gap-2"
disabled={downloadDataMutation.isPending}
>
<Download className="w-4 h-4" />
Download
</button>
</div>
<div className="border-t dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-red-600 dark:text-red-400">Delete Account</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Permanently delete your account and all data</p>
</div>
<button
onClick={handleDeleteAccount}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Edit Profile Modal */}
{showEditProfile && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4 flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Edit Profile</h3>
<button
onClick={() => setShowEditProfile(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleEditProfile} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
type="text"
value={editForm.fullName}
onChange={(e) => setEditForm({ ...editForm, fullName: e.target.value })}
className="input-field"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Date of Birth</label>
<input
type="date"
value={editForm.dateOfBirth}
onChange={(e) => setEditForm({ ...editForm, dateOfBirth: e.target.value })}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Gender</label>
<select
value={editForm.gender}
onChange={(e) => setEditForm({ ...editForm, gender: e.target.value })}
className="input-field"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Nationality</label>
<select
value={editForm.nationality}
onChange={(e) => setEditForm({ ...editForm, nationality: e.target.value })}
className="input-field"
>
<option value="">Select nationality</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email *</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
className="input-field"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Phone</label>
<input
type="tel"
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
className="input-field"
placeholder="+251911234567"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowEditProfile(false)}
className="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Change Password Modal */}
{showChangePassword && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4 flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Change Password</h3>
<button
onClick={() => setShowChangePassword(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleChangePassword} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Current Password</label>
<input
type="password"
name="currentPassword"
className="input-field"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">New Password</label>
<input
type="password"
name="newPassword"
className="input-field"
required
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Confirm New Password</label>
<input
type="password"
name="confirmPassword"
className="input-field"
required
minLength={6}
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowChangePassword(false)}
className="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
)}
{showModal && (
<CustomModal
isOpen={showModal}
type={modalConfig.type}
title={modalConfig.title}
message={modalConfig.message}
onClose={() => setShowModal(false)}
onConfirm={modalConfig.onConfirm}
showCancel={modalConfig.type === 'warning' || modalConfig.type === 'error'}
confirmText={modalConfig.type === 'warning' || modalConfig.type === 'error' ? 'Yes' : 'OK'}
cancelText="Cancel"
/>
)}
</div>
);
}

View File

@@ -1,10 +1,19 @@
'use client';
import { Train } from 'lucide-react';
import { Train, LogOut, User, BookOpen, LogIn } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import Link from 'next/link';
import { useAuthStore } from '@/lib/auth-store';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function AppHeader() {
const { user, isAuthenticated, initialize } = useAuthStore();
const router = useRouter();
useEffect(() => {
initialize();
}, [initialize]);
return (
<header className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4">
@@ -26,8 +35,35 @@ export default function AppHeader() {
</Link>
{/* Right side actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{!isAuthenticated ? (
<Link
href="/login"
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Sign In"
>
<LogIn className="w-5 h-5" />
</Link>
) : (
<Link
href="/profile"
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Profile"
>
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{user?.fullName}
</span>
</Link>
)}
<ThemeToggle />
<Link
href="/guide"
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="How to Book"
>
<BookOpen className="w-5 h-5" />
</Link>
</div>
</div>
</div>

View File

@@ -68,17 +68,17 @@ export default function ModernDatePicker({
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
if (newType === 'ethiopian') {
// Sync Ethiopian view to current Gregorian view
const currentViewDate = new Date(viewYear, viewMonth, 15);
const ethDate = gregorianToEthiopian(currentViewDate);
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view
// Use today's date if no value is selected, otherwise use the selected value
const referenceDate = value || new Date();
const ethDate = gregorianToEthiopian(referenceDate);
setEthViewMonth(ethDate.month);
setEthViewYear(ethDate.year);
} else {
// Sync Gregorian view to current Ethiopian view
const currentEthDate = { year: ethViewYear, month: ethViewMonth, day: 15 };
const gregDate = ethiopianToGregorian(currentEthDate);
setViewMonth(gregDate.getMonth());
setViewYear(gregDate.getFullYear());
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view
const referenceDate = value || new Date();
setViewMonth(referenceDate.getMonth());
setViewYear(referenceDate.getFullYear());
}
setCalendarType(newType);

View File

@@ -27,23 +27,21 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
return (
<nav aria-label="Progress" className="py-6">
<ol className="flex items-center justify-between max-w-4xl mx-auto">
<ol className="flex items-center max-w-4xl mx-auto">
{steps.map((step, index) => {
const isComplete = index < currentIndex;
const isCurrent = index === currentIndex;
return (
<li key={step.id} className="relative flex-1 flex flex-col items-center">
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
<div className="flex items-center w-full">
{index > 0 && (
<div
className={`flex-1 h-1 transition-all duration-300 ${
isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
<div
className={`relative flex h-10 w-10 items-center justify-center rounded-full transition-all duration-300 ${
className={`flex-1 h-1 transition-all duration-300 ${
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
<div
className={`relative flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
isComplete
? 'bg-primary shadow-lg scale-110'
: isCurrent
@@ -63,21 +61,23 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
</span>
)}
</div>
{index < steps.length - 1 && (
<div
className={`flex-1 h-1 transition-all duration-300 ${
isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
<div
className={`flex-1 h-1 transition-all duration-300 ${
index === steps.length - 1 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
</div>
<div className="flex items-center w-full">
<div className={`flex-1 ${index === 0 ? 'opacity-0' : ''}`} />
<p
className={`mt-3 text-xs md:text-sm font-medium transition-colors flex-shrink-0 ${
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
}`}
>
{step.name}
</p>
<div className={`flex-1 ${index === steps.length - 1 ? 'opacity-0' : ''}`} />
</div>
<p
className={`mt-3 text-xs md:text-sm font-medium transition-colors ${
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
}`}
>
{step.name}
</p>
</li>
);
})}

View File

@@ -37,18 +37,17 @@ export default function ThemeToggle() {
// Prevent hydration mismatch by not rendering until mounted
if (!mounted) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 w-[88px] h-[40px]" />
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-800" />
);
}
return (
<button
onClick={cycleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={`Current theme: ${getLabel()}. Click to cycle.`}
>
{getIcon()}
<span className="text-sm font-medium hidden sm:inline">{getLabel()}</span>
</button>
);
}

View File

@@ -25,7 +25,11 @@ class ApiClient {
(response) => response,
(error) => {
if (error.response?.status === 401) {
if (typeof window !== 'undefined') {
// Don't redirect if it's a login or register request (invalid credentials)
const isAuthEndpoint = error.config?.url?.includes('/auth/login') ||
error.config?.url?.includes('/auth/register');
if (!isAuthEndpoint && typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
window.location.href = '/login';

View File

@@ -7,17 +7,25 @@ interface User {
fullName: string;
phone?: string;
role: string;
dateOfBirth?: string;
gender?: string;
nationality?: string;
faydaVerified?: boolean;
faydaSub?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
setUser: (user: User, token: string) => void;
initialize: () => void;
updateUser: (userData: Partial<User>) => void;
initialize: () => Promise<void>;
fetchProfile: () => Promise<void>;
}
interface RegisterData {
@@ -27,23 +35,57 @@ interface RegisterData {
password: string;
}
export const useAuthStore = create<AuthState>((set) => ({
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isInitialized: false,
initialize: () => {
initialize: async () => {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('auth_token');
const userStr = localStorage.getItem('auth_user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
set({ user, token, isAuthenticated: true });
set({ user, token, isAuthenticated: true, isInitialized: true });
// Fetch fresh profile data in background
get().fetchProfile().catch(() => {
// If profile fetch fails, token might be expired
console.warn('Failed to fetch profile, token might be expired');
});
} catch (e) {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
set({ isInitialized: true });
}
} else {
set({ isInitialized: true });
}
},
fetchProfile: async () => {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const response: any = await apiClient.get('/auth/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
const userData = response.data || response;
localStorage.setItem('auth_user', JSON.stringify(userData));
set({ user: userData });
} catch (error: any) {
// If 401, token is invalid - logout
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
set({ user: null, token: null, isAuthenticated: false });
}
throw error;
}
},
@@ -67,10 +109,33 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user, token, isAuthenticated: true });
},
logout: () => {
logout: async () => {
try {
const token = localStorage.getItem('auth_token');
if (token) {
// Call logout endpoint to invalidate session on backend
await apiClient.post('/auth/logout', {}, {
headers: {
'Authorization': `Bearer ${token}`
}
});
}
} catch (error) {
console.error('Logout API call failed:', error);
// Continue with logout even if API call fails
}
// Clear local storage and state
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
set({ user: null, token: null, isAuthenticated: false });
// Redirect to home page after state is updated
if (typeof window !== 'undefined') {
setTimeout(() => {
window.location.href = '/';
}, 100);
}
},
setUser: (user: User, token: string) => {
@@ -78,5 +143,12 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.setItem('auth_user', JSON.stringify(user));
set({ user, token, isAuthenticated: true });
},
updateUser: (userData: Partial<User>) => {
const currentUser = JSON.parse(localStorage.getItem('auth_user') || '{}');
const updatedUser = { ...currentUser, ...userData };
localStorage.setItem('auth_user', JSON.stringify(updatedUser));
set({ user: updatedUser });
},
})
);

View File

@@ -19,9 +19,15 @@ export interface PassengerDetail {
faydaSub?: string;
passportNumber?: string;
passportCountry?: string;
passportIssueDate?: string;
passportExpiryDate?: string;
passportIssuingAuthority?: string;
idDocumentType?: string;
isPrimaryPassenger: boolean;
seatId?: string;
phone?: string;
email?: string;
gender?: string;
}
export interface SelectedSchedule {