mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Updated the passenger web portal and added more endpoints to the api
This commit is contained in:
70
README.md
70
README.md
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -71,11 +71,15 @@ 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) {
|
||||
// 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(
|
||||
@@ -85,14 +89,24 @@ export class GuestBookingService {
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
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: 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,
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
if (originStationId === destinationStationId) {
|
||||
alert('Origin and destination must be different');
|
||||
return;
|
||||
}
|
||||
return stopData;
|
||||
});
|
||||
|
||||
// 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,30 +361,37 @@ 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 max-h-64 overflow-y-auto">
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex gap-2 items-start p-3 bg-muted/50 rounded">
|
||||
<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">
|
||||
{index + 1}
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
|
||||
{/* Intermediate Stops */}
|
||||
{stops.map((stop, index) => (
|
||||
<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">
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={stop.stationId}
|
||||
@@ -283,26 +399,29 @@ export default function RoutesPage() {
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
{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>
|
||||
<div className="w-32">
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -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([]);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
|
||||
@@ -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',
|
||||
const handleBlock = (seat: any) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowBlockModal(true);
|
||||
};
|
||||
return (
|
||||
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
|
||||
{seatClass.replace(/_/g, ' ')}
|
||||
</span>
|
||||
|
||||
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
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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',
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<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 seats..."
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +162,6 @@ 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));
|
||||
|
||||
@@ -168,43 +184,23 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
}
|
||||
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
useEffect(() => {
|
||||
const checkFaydaStatus = async () => {
|
||||
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' });
|
||||
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) {
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'error' });
|
||||
console.error('Failed to update user profile:', error);
|
||||
} finally {
|
||||
setVerifying(null);
|
||||
setUpdatingUser(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
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) {
|
||||
console.error('Failed to save passenger details:', error);
|
||||
alert('Failed to save passenger details. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,51 +234,72 @@ 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>
|
||||
</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
|
||||
<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>
|
||||
)}
|
||||
{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 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>
|
||||
<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 && (
|
||||
@@ -174,22 +308,67 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
<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"
|
||||
@@ -201,7 +380,7 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<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`)}
|
||||
@@ -213,25 +392,108 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number</label>
|
||||
<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 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 Country</label>
|
||||
<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"
|
||||
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">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>
|
||||
|
||||
@@ -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() {
|
||||
});
|
||||
};
|
||||
|
||||
// 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');
|
||||
return null;
|
||||
}
|
||||
}, 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 => ({
|
||||
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: p.nationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
|
||||
idDocumentNumber: p.nationalId || p.passportNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
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,
|
||||
})),
|
||||
createAccount: createAccount,
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
// Only redirect to search if we're not in the middle of creating a booking
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (typeof window !== 'undefined') {
|
||||
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>
|
||||
|
||||
@@ -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"><5 years • First child free</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400"><5 years • First child free</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -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}
|
||||
|
||||
245
apps/edr-passenger-web/portal/src/app/guide/page.tsx
Normal file
245
apps/edr-passenger-web/portal/src/app/guide/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
818
apps/edr-passenger-web/portal/src/app/profile/page.tsx
Normal file
818
apps/edr-passenger-web/portal/src/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
index === 0 ? 'opacity-0' : 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={`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'
|
||||
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 ${
|
||||
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>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user