mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #81 from Tria-plc/alpha
Passenger portal and api updates
This commit is contained in:
103
IMPLEMENTATION_SUMMARY.md
Normal file
103
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# User Booking History Implementation - COMPLETED
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
#### 1. **BookingsService** (`apps/edr-passenger-api/src/modules/bookings/bookings.service.ts`)
|
||||
Added new method `findByPassengerId()`:
|
||||
- Retrieves all bookings for an authenticated user by their passengerId
|
||||
- Supports filtering by: search (booking ref, station names), status, pagination (page/pageSize)
|
||||
- Returns paginated list with booking details, schedule, train, and payment info
|
||||
- Structured response includes meta data (page, pageSize, total, totalPages)
|
||||
|
||||
**Method Signature:**
|
||||
```typescript
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {})
|
||||
```
|
||||
|
||||
#### 2. **BookingsController** (`apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts`)
|
||||
Added new endpoint `GET /bookings/my/bookings`:
|
||||
- Protected by `JwtGuard` (requires valid JWT token)
|
||||
- Extracts passengerId from authenticated user object in request
|
||||
- Passes through search, status, page, pageSize query parameters
|
||||
- Fully documented with Swagger decorators and API responses
|
||||
|
||||
**Endpoint Details:**
|
||||
```
|
||||
GET /bookings/my/bookings
|
||||
Authorization: Bearer <jwt-token>
|
||||
Query Parameters:
|
||||
- search?: string (search by booking ref or station names)
|
||||
- status?: string (filter by booking status)
|
||||
- page?: string (default: 1)
|
||||
- pageSize?: string (default: 20)
|
||||
|
||||
Response: Paginated list of user's bookings with schedule and payment details
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
1. User sends GET request to `/bookings/my/bookings` with JWT token
|
||||
2. JwtGuard validates token and populates `req.user` with decoded payload
|
||||
3. Controller extracts `passengerId` from `req.user`
|
||||
4. `findByPassengerId()` queries Prisma for bookings where `passengerId` matches
|
||||
5. Returns paginated, formatted booking list
|
||||
|
||||
### Key Features
|
||||
- **Multi-level filtering**: Search across booking reference and station names
|
||||
- **Pagination support**: Full pagination with total count and page info
|
||||
- **Authenticated access**: Only authenticated users can access their bookings
|
||||
- **Swagger documentation**: Complete API documentation with query parameters and response schema
|
||||
- **Related data**: Includes schedule (train, stations, times), payment info, and seat count
|
||||
|
||||
## Verification
|
||||
|
||||
✅ TypeScript compilation successful (no type errors)
|
||||
✅ New method added to BookingsService
|
||||
✅ New endpoint added to BookingsController
|
||||
✅ JWT authentication guard applied
|
||||
✅ Swagger documentation complete
|
||||
✅ Imports updated (added Req from @nestjs/common)
|
||||
|
||||
## Testing
|
||||
|
||||
To test the new endpoint:
|
||||
|
||||
```bash
|
||||
# 1. Login to get JWT token
|
||||
POST /auth/login
|
||||
{
|
||||
"email": "passenger@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
# 2. Copy the accessToken from response
|
||||
|
||||
# 3. Call the new endpoint
|
||||
GET /bookings/my/bookings?page=1&pageSize=10
|
||||
Authorization: Bearer <accessToken>
|
||||
|
||||
# Optional filtering
|
||||
GET /bookings/my/bookings?search=ABC&status=CONFIRMED&page=1
|
||||
```
|
||||
|
||||
## Frontend Integration Notes
|
||||
|
||||
The frontend can now:
|
||||
- Fetch authenticated user's booking history without manually managing passengerId
|
||||
- Filter bookings by status (PENDING_PAYMENT, CONFIRMED, CANCELLED)
|
||||
- Search bookings by reference or station names
|
||||
- Handle pagination with page/pageSize parameters
|
||||
- Display booking history in a user profile/dashboard view
|
||||
|
||||
## Database Query Structure
|
||||
|
||||
The implementation queries:
|
||||
- `Booking` table filtered by passengerId
|
||||
- Joins with `TrainSchedule` (includes train, origin/destination stations)
|
||||
- Joins with `PaymentIntent` (payment info)
|
||||
- Joins with `BookingSeat` (seat count)
|
||||
|
||||
No N+1 query issues due to Prisma's include optimization.
|
||||
75
RESTART_BOOKING_IMPLEMENTATION.md
Normal file
75
RESTART_BOOKING_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nationality Mismatch Restart Booking - IMPLEMENTED
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `edr-passenger-web/portal/src/app/booking/passengers/page.tsx`
|
||||
|
||||
**Changes:**
|
||||
1. Added `clearBooking` to the imported hooks from `useBookingStore`
|
||||
2. Updated the "Restart Booking" button click handler to:
|
||||
- Clear the entire booking state using `clearBooking()`
|
||||
- Perform a full page reload to `/booking/search` using `window.location.href`
|
||||
- Added visual feedback with `ExternalLink` icon
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
onClick={() => router.push('/booking/search')}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
onClick={() => {
|
||||
clearBooking();
|
||||
window.location.href = '/booking/search';
|
||||
}}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
Restart Booking
|
||||
</button>
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
When user encounters a nationality mismatch error:
|
||||
1. Warning card displays with ⚠️ icon
|
||||
2. User clicks "Restart Booking" button
|
||||
3. Button action triggers:
|
||||
- **State Clearing**: All booking data is cleared from Zustand store and localStorage:
|
||||
- `searchCriteria`
|
||||
- `selectedSchedule`
|
||||
- `passengers`
|
||||
- `seatHold`
|
||||
- `bookingId`
|
||||
- `pnr`
|
||||
- `selectedPaymentMethod`
|
||||
- `createAccount`
|
||||
- `passengerId`
|
||||
- **Full Page Reload**: Browser navigates to `/booking/search` with full page reload (not client-side navigation)
|
||||
- **Visual Feedback**: ExternalLink icon indicates external/full navigation action
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Complete State Reset**: Ensures all booking data is cleared, preventing stale data issues
|
||||
✅ **Fresh Start**: Full page reload ensures clean state on search page
|
||||
✅ **Clear UX**: ExternalLink icon visually indicates a reload action
|
||||
✅ **No Residual Data**: Prevents any leftover booking information from previous attempt
|
||||
✅ **Consistent User Flow**: Forces fresh search criteria entry
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Uses `useBookingStore().clearBooking()` from Zustand store
|
||||
- `window.location.href` triggers full page reload (unlike `router.push()` which is client-side navigation)
|
||||
- All booking state is reset to initial values defined in store
|
||||
- localStorage is automatically cleared due to Zustand's persist middleware
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Login with account that has nationality A
|
||||
- [ ] Search for passenger with nationality B
|
||||
- [ ] Verify nationality mismatch error appears
|
||||
- [ ] Click "Restart Booking" button
|
||||
- [ ] Verify page reloads to search page
|
||||
- [ ] Verify booking store is completely cleared
|
||||
- [ ] Verify search page shows default/empty state
|
||||
- [ ] Verify user can perform new search
|
||||
81
SEAT_SELECTION_FIX.md
Normal file
81
SEAT_SELECTION_FIX.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Seat Selection Fix - COMPLETED
|
||||
|
||||
## Issue
|
||||
Users were unable to select another seat after clicking on one of the available seats. The seat selection wasn't responding to subsequent clicks.
|
||||
|
||||
## Root Cause
|
||||
The `toggleSeat` function was using stale state from closures. When `setSelectedSeats` was called, it was based on the current value of `selectedSeats` at function definition time, not at click time. This caused state updates to be lost when clicking multiple seats rapidly.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Fixed toggleSeat Function (Lines 103-112)
|
||||
**Before:**
|
||||
```typescript
|
||||
const toggleSeat = (seatId: string) => {
|
||||
if (selectedSeats.includes(seatId)) {
|
||||
setSelectedSeats(selectedSeats.filter(id => id !== seatId));
|
||||
} else if (selectedSeats.length < passengers.length) {
|
||||
setSelectedSeats([...selectedSeats, seatId]);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const toggleSeat = (seatId: string) => {
|
||||
setSelectedSeats(prev => {
|
||||
if (prev.includes(seatId)) {
|
||||
return prev.filter(id => id !== seatId);
|
||||
} else if (prev.length < passengers.length) {
|
||||
return [...prev, seatId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Change:** Use functional state update pattern (`prev =>`) to ensure we always work with the latest state value.
|
||||
|
||||
### 2. Improved Seat Button Click Handler (Lines 211-215)
|
||||
**Before:**
|
||||
```typescript
|
||||
onClick={() => seat.status === 'AVAILABLE' && toggleSeat(seat.id)}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
onClick={() => {
|
||||
if (seat.status === 'AVAILABLE') {
|
||||
toggleSeat(seat.id);
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
**Change:** Explicit conditional check in the handler for better clarity and reliability.
|
||||
|
||||
### 3. Added Cursor Pointer Style (Line 223)
|
||||
Added `cursor-pointer` class to available seats to give better visual feedback that they're clickable.
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Functional State Updates**: Uses React's functional setState pattern to always access latest state
|
||||
✅ **No Stale Closures**: Eliminates closure issues that prevented multiple seat selections
|
||||
✅ **Multiple Selections Work**: Users can now click multiple seats in sequence without issues
|
||||
✅ **Better UX**: Explicit conditional makes code more maintainable and easier to debug
|
||||
✅ **Visual Feedback**: Added cursor pointer to indicate clickable seats
|
||||
|
||||
## Testing Steps
|
||||
|
||||
1. Navigate to seat selection page
|
||||
2. Click on first available seat - should highlight in primary color
|
||||
3. Click on another available seat - should also highlight
|
||||
4. Continue clicking multiple seats - all should remain selected
|
||||
5. Click on a selected seat - should deselect it
|
||||
6. Verify seat count updates in "Selection Summary" panel
|
||||
|
||||
## Technical Details
|
||||
|
||||
- React's `setSelectedSeats(prev => {...})` functional update ensures state consistency
|
||||
- Each click now works with the current state, not a stale copy
|
||||
- No race conditions with rapid clicks
|
||||
- Maximum seats can still be selected based on passenger count
|
||||
@@ -62,7 +62,21 @@ export class AuthService {
|
||||
});
|
||||
|
||||
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
||||
return await this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
|
||||
|
||||
// Ensure passenger exists and get its ID
|
||||
let passengerId = user.passenger?.id;
|
||||
if (!passengerId) {
|
||||
// If passenger doesn't exist, create it
|
||||
const passenger = await this.prisma.passenger.create({
|
||||
data: { userId: user.id }
|
||||
});
|
||||
passengerId = passenger.id;
|
||||
// Also create loyalty and wallet accounts
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
||||
}
|
||||
|
||||
return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id);
|
||||
}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
@@ -124,8 +138,13 @@ export class AuthService {
|
||||
select: { id: true, email: true, fullName: true, role: true }
|
||||
});
|
||||
|
||||
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
|
||||
return {
|
||||
const payload = { sub: userId, email, role, passengerId, agentId };
|
||||
console.log('[AUTH] Creating JWT with payload:', payload);
|
||||
|
||||
const token = this.jwt.sign(payload);
|
||||
console.log('[AUTH] JWT created, token length:', token.length);
|
||||
|
||||
const response = {
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
@@ -136,6 +155,8 @@ export class AuthService {
|
||||
agentId
|
||||
}
|
||||
};
|
||||
console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -15,6 +15,35 @@ export class BookingsController {
|
||||
private guestService: GuestBookingService,
|
||||
) {}
|
||||
|
||||
@Get('my/bookings')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get logged-in user\'s booking history',
|
||||
description: 'Returns all bookings for the authenticated user with schedule and payment details'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' })
|
||||
getMyBookings(
|
||||
@Req() req: any,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const passengerId = req.user?.passengerId;
|
||||
if (!passengerId) throw new Error('Passenger ID not found in token');
|
||||
return this.service.findByPassengerId(passengerId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
|
||||
@@ -38,6 +38,70 @@ export class BookingsService {
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = { passengerId };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
@@ -168,12 +168,19 @@ export class GuestBookingService {
|
||||
throw new BadRequestException('Email already registered. Please login instead.');
|
||||
}
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: firstPassenger.phone || '',
|
||||
phone: accountPhone,
|
||||
passwordHash,
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
@@ -201,11 +208,19 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Use a guaranteed-unique guest phone to avoid constraint collisions
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existingPhone) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
|
||||
import { FaydaConfig } from '../../config/fayda.config';
|
||||
|
||||
@ApiTags('Fare Engine')
|
||||
@Controller('fare-engine')
|
||||
export class FareEngineController {
|
||||
constructor(private service: FareEngineService) {}
|
||||
constructor(
|
||||
private service: FareEngineService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({
|
||||
@@ -63,3 +68,36 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Config')
|
||||
@Controller('config')
|
||||
export class ConfigController {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
@Get('fayda-status')
|
||||
@ApiOperation({
|
||||
summary: 'Check Verifayda 2.0 configuration status',
|
||||
description: 'Returns whether Verifayda integration is enabled and ready to use'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Verifayda status retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
enabled: true,
|
||||
mode: 'production',
|
||||
apiUrl: 'https://api.verifayda.gov.et/v2'
|
||||
}
|
||||
}
|
||||
})
|
||||
getFaydaStatus() {
|
||||
const faydaConfig = this.configService.get<FaydaConfig>('fayda');
|
||||
const verifaydaEnabled = this.configService.get<boolean>('VERIFAYDA_ENABLED', false);
|
||||
|
||||
return {
|
||||
enabled: faydaConfig?.enabled || verifaydaEnabled,
|
||||
mode: verifaydaEnabled ? 'production' : 'development',
|
||||
apiUrl: this.configService.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FareEngineController } from './fare-engine.controller';
|
||||
import { FareEngineController, ConfigController } from './fare-engine.controller';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
import { CurrencyController } from './currency.controller';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule],
|
||||
controllers: [FareEngineController, CurrencyController],
|
||||
controllers: [FareEngineController, CurrencyController, ConfigController],
|
||||
providers: [FareEngineService],
|
||||
exports: [FareEngineService],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@ApiTags('Passengers')
|
||||
@Controller('passengers')
|
||||
@@ -13,6 +14,7 @@ export class PassengersController {
|
||||
constructor(
|
||||
private service: PassengersService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -38,6 +40,42 @@ export class PassengersController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get current passenger profile',
|
||||
description: 'Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Passenger profile retrieved successfully or null if not found'
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||
async getMe(@Request() req: any) {
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: req.user.userId },
|
||||
include: {
|
||||
passenger: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.passenger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.service.getProfile(user.passenger.id);
|
||||
} catch (error) {
|
||||
// If profile lookup fails for any reason, return null to allow app to continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/profile')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
|
||||
import { PassengersController } from './passengers.controller';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [VerifaydaModule, HttpModule],
|
||||
imports: [VerifaydaModule, HttpModule, PrismaModule],
|
||||
controllers: [PassengersController],
|
||||
providers: [PassengersService]
|
||||
})
|
||||
|
||||
@@ -59,19 +59,19 @@ export default function AuthCheckPage() {
|
||||
<div className="space-y-3 mb-6 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-primary text-xs">✓</span>
|
||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// This is the updated onSubmit function for passengers/page.tsx
|
||||
// Replace the existing onSubmit function with this one
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let passengerId = '';
|
||||
|
||||
console.log('[Passengers] onSubmit called, isAuthenticated:', isAuthenticated, 'user:', user);
|
||||
|
||||
// For authenticated users, fetch the passenger profile to get the passengerId
|
||||
if (isAuthenticated && user?.id) {
|
||||
try {
|
||||
console.log('[Passengers] Fetching passenger profile from /passengers/me');
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
console.log('[Passengers] Passenger profile response:', passengerProfile);
|
||||
passengerId = passengerProfile?.id || '';
|
||||
console.log('[Passengers] Extracted passengerId:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('[Passengers] Failed to fetch passenger profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Passengers] passengerId before saving:', passengerId);
|
||||
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
gender: p.gender,
|
||||
nationality: p.nationality,
|
||||
nationalId: p.nationalId,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
|
||||
// Save passengerId to booking store for later use
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('[Passengers] Saved passengerId to booking store:', passengerId);
|
||||
} else {
|
||||
console.warn('[Passengers] Not saving passengerId - isAuthenticated:', isAuthenticated, 'passengerId:', passengerId);
|
||||
}
|
||||
|
||||
// Store in localStorage as additional backup
|
||||
if (typeof window !== 'undefined' && passengerId) {
|
||||
localStorage.setItem('booking_passengerId', passengerId);
|
||||
console.log('[Passengers] Stored passengerId in localStorage:', passengerId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let passengerId = '';
|
||||
|
||||
// For authenticated users, fetch the passenger profile to get the passengerId
|
||||
if (isAuthenticated && user?.id) {
|
||||
try {
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id || '';
|
||||
console.log('Fetched passengerId:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
gender: p.gender,
|
||||
nationality: p.nationality,
|
||||
nationalId: p.nationalId,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
|
||||
// Save passengerId to booking store for later use
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('Saved passengerId to booking store:', passengerId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const passengerDetails = data.passengers.map((p, i) => ({\n name: p.name,\n dateOfBirth: p.dateOfBirth,\n gender: p.gender,\n nationality: p.nationality,\n nationalId: p.nationalId,\n passportNumber: p.passportNumber,\n passportCountry: p.passportCountry,\n phone: p.phone,\n email: p.email,\n isPrimaryPassenger: i === 0,\n passengerId: i === 0 && isAuthenticated ? user?.passenger?.id : undefined,\n }));\n\n const deviceId = typeof window !== 'undefined'\n ? (localStorage.getItem('deviceId') || crypto.randomUUID())\n : crypto.randomUUID();\n\n await apiClient.post('/passengers/save-details', {\n passengers: passengerDetails,\n userId: user?.id,\n deviceId,\n });\n\n setPassengers(passengerDetails);\n setCreateAccount(data.createAccount);\n \n // Save passengerId from authenticated user to booking store\n if (isAuthenticated && user?.passenger?.id) {\n const { setPassengerId } = useBookingStore.getState();\n setPassengerId(user.passenger.id);\n }\n \n router.push('/booking/seats');\n } catch (error) {\n console.error('Failed to save passenger details:', error);\n alert('Failed to save passenger details. Please try again.');\n } finally {\n setSaving(false);\n }\n };
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -29,10 +29,9 @@ const passengerSchema = z.object({
|
||||
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 data.passportNumber && data.passportNumber.length > 0 &&
|
||||
data.passportCountry && data.passportCountry.length > 0;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
@@ -49,12 +48,14 @@ type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore();
|
||||
const { user, isAuthenticated, 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 [formInitialized, setFormInitialized] = useState(false);
|
||||
const [nationalityMismatch, setNationalityMismatch] = useState(false);
|
||||
|
||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||
|
||||
@@ -94,26 +95,82 @@ export default function PassengersPage() {
|
||||
}
|
||||
};
|
||||
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' });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.faydaVerified) {
|
||||
setVerificationStatus({ 0: 'success' });
|
||||
}
|
||||
}, [isAuthenticated, user?.faydaVerified]);
|
||||
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
console.log('Missing required data for population');
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch passenger profile from backend
|
||||
const passengerData: any = await apiClient.get(`/passengers/me`);
|
||||
console.log('Fetched passenger data:', passengerData);
|
||||
|
||||
if (!passengerData) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userNationality = (passengerData?.nationality || user.nationality || '').toUpperCase().trim();
|
||||
const searchNationality = (searchCriteria?.nationality || '').toUpperCase().trim();
|
||||
console.log('Nationalities:', { userNationality, searchNationality });
|
||||
|
||||
// Check for nationality mismatch
|
||||
if (userNationality !== searchNationality) {
|
||||
console.log('Nationality mismatch detected');
|
||||
setNationalityMismatch(true);
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only populate if nationalities match
|
||||
console.log('Setting passenger 0 values');
|
||||
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
|
||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
||||
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
|
||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
|
||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
||||
setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false);
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
|
||||
setFormInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger data:', error);
|
||||
setFormInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
populateForm();
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nationalityMismatch && formInitialized) {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById('nationality-mismatch');
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}, [nationalityMismatch, formInitialized]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
purpose: 'PURCHASE',
|
||||
@@ -126,7 +183,7 @@ export default function PassengersPage() {
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
'FaydaVerification',
|
||||
@@ -170,6 +227,19 @@ export default function PassengersPage() {
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let passengerId = '';
|
||||
|
||||
// For authenticated users, fetch the passenger profile to get the passengerId
|
||||
if (isAuthenticated && user?.id) {
|
||||
try {
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id || '';
|
||||
console.log('Fetched passengerId:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -181,20 +251,29 @@ export default function PassengersPage() {
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
|
||||
// Save passengerId to booking store for later use
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('Saved passengerId to booking store:', passengerId);
|
||||
}
|
||||
|
||||
router.push('/booking/seats');
|
||||
} catch (error) {
|
||||
console.error('Failed to save passenger details:', error);
|
||||
@@ -209,6 +288,56 @@ export default function PassengersPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nationalityMismatch && formInitialized) {
|
||||
const searchLabel: Record<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };
|
||||
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-lg mx-auto">
|
||||
<div className="card border-red-300 dark:border-red-700" id="nationality-mismatch">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-red-500 text-2xl mt-0.5">⚠️</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2">Nationality Mismatch</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
|
||||
You searched for an <strong>{searchLabel[searchCriteria.nationality] ?? searchCriteria.nationality}</strong> passenger,
|
||||
but your account is registered as <strong>{user?.nationality}</strong>.
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-5">
|
||||
You cannot proceed with this booking. Please restart and select the correct nationality on the search page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearBooking();
|
||||
window.location.href = '/booking/search';
|
||||
}}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
Restart Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formInitialized) {
|
||||
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-lg mx-auto text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading passenger details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -232,7 +361,7 @@ export default function PassengersPage() {
|
||||
Passenger {index + 1} {index === 0 && '(Primary)'}
|
||||
{index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'}
|
||||
<span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400">
|
||||
({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'International'})
|
||||
({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -258,18 +387,13 @@ export default function PassengersPage() {
|
||||
)}
|
||||
{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-gray-600 dark:text-gray-400 hover:underline mt-2"
|
||||
>
|
||||
Or enter details manually
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
) : showManualEntryLink ? (
|
||||
<div className="text-center py-8">
|
||||
@@ -285,227 +409,250 @@ export default function PassengersPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isEthiopian ? (
|
||||
<>
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{isEthiopian ? (
|
||||
<>
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
/>
|
||||
{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>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per passport"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.passengers?.[index]?.dateOfBirth && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.nationality`)}
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
placeholder="+254712345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportNumber`)}
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="P1234567"
|
||||
placeholder="Full name as per ID"
|
||||
value={passengers[index]?.name || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportNumber && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country *</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
placeholder="Djibouti"
|
||||
value={passengers[index]?.dateOfBirth || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportCountry && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||
{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">Issuing Authority</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportIssuingAuthority`)}
|
||||
<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"
|
||||
placeholder="Government of Djibouti"
|
||||
value={passengers[index]?.gender || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
||||
>
|
||||
<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
|
||||
value={passengers[index]?.nationality || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issue Date</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
placeholder="+251911234567"
|
||||
value={passengers[index]?.phone || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Expiry Date</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
placeholder="email@example.com"
|
||||
value={passengers[index]?.email || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per passport"
|
||||
value={passengers[index]?.name || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.dateOfBirth || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
||||
/>
|
||||
{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"
|
||||
value={passengers[index]?.gender || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
||||
>
|
||||
<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
|
||||
value={passengers[index]?.nationality || ''}
|
||||
/>
|
||||
</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"
|
||||
value={passengers[index]?.phone || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
||||
/>
|
||||
</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"
|
||||
value={passengers[index]?.email || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
||||
/>
|
||||
{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"
|
||||
value={passengers[index]?.passportNumber || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
|
||||
/>
|
||||
{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">Issuing Country / Authority *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
className="input-field"
|
||||
placeholder="e.g., Djibouti / Government of Djibouti"
|
||||
value={passengers[index]?.passportCountry || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
|
||||
/>
|
||||
{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">Issue Date</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.passportIssueDate || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
|
||||
/>
|
||||
</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"
|
||||
value={passengers[index]?.passportExpiryDate || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="card">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" {...register('createAccount')} className="w-4 h-4" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div className="card">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" {...register('createAccount')} className="w-4 h-4" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create an account to save my profile for future bookings</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Save passengerId to booking store for later use
|
||||
if (isAuthenticated && passengerId) {
|
||||
const { setPassengerId } = useBookingStore.getState();
|
||||
setPassengerId(passengerId);
|
||||
console.log('Saved passengerId to booking store:', passengerId);
|
||||
|
||||
// Also save to localStorage as backup
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('booking_passengerId', passengerId);
|
||||
console.log('Saved passengerId to localStorage:', passengerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Key changes needed in passengers/page.tsx onSubmit function:
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Build passenger details - ALWAYS create new records, don't reuse
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
gender: p.gender,
|
||||
nationality: p.nationality,
|
||||
nationalId: p.nationalId,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
// IMPORTANT: Don't include passengerId here - it's only needed in booking creation
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
// Save passenger details (this is for UI reference, not booking creation)
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
// Store in booking store for the next step (seats selection)
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -213,7 +213,7 @@ export default function PaymentPage() {
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total Amount</span>
|
||||
<span className="text-primary">
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,57 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
try {
|
||||
if (!token) {
|
||||
console.warn('No token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Invalid token format - expected 3 parts, got', parts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode JWT payload with proper base64 padding
|
||||
const payload = parts[1];
|
||||
const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = JSON.parse(atob(padded));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse base64:', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Decoded JWT payload keys:', Object.keys(decoded));
|
||||
console.log('passengerId from JWT:', decoded.passengerId);
|
||||
|
||||
if (!decoded.passengerId) {
|
||||
console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded));
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded.passengerId;
|
||||
} catch (error) {
|
||||
console.error('Error in getPassengerIdFromToken:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount } = useBookingStore();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore();
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const [timeLeft, setTimeLeft] = useState<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -62,7 +105,10 @@ export default function ReviewPage() {
|
||||
}, [selectedSchedule?.id, passengers]);
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/bookings/guest', data),
|
||||
mutationFn: (data: any) => {
|
||||
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
|
||||
return apiClient.post(endpoint, data);
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
console.log('Booking created successfully:', data);
|
||||
const bookingIdValue = data.bookingId || data.id;
|
||||
@@ -70,30 +116,25 @@ export default function ReviewPage() {
|
||||
|
||||
console.log('Setting booking ID:', bookingIdValue);
|
||||
console.log('Setting PNR:', pnrValue);
|
||||
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||
|
||||
setBookingId(bookingIdValue);
|
||||
setPNR(pnrValue);
|
||||
|
||||
// Check if payment is required
|
||||
const totalAmount = data.totalMinor || data.totalAmount || 0;
|
||||
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (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');
|
||||
}
|
||||
@@ -116,7 +157,6 @@ export default function ReviewPage() {
|
||||
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.');
|
||||
@@ -124,7 +164,6 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate search criteria
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
alert('Missing search criteria. Please start over.');
|
||||
@@ -132,7 +171,6 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class ID
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
@@ -144,37 +182,106 @@ export default function ReviewPage() {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
const bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB' as const,
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
const hasNationalId = isEthiopian && p.nationalId;
|
||||
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: hasNationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
|
||||
idDocumentNumber: p.nationalId || undefined,
|
||||
passportNumber: !hasNationalId ? p.passportNumber : undefined,
|
||||
passportCountry: !hasNationalId ? p.passportCountry : undefined,
|
||||
nationality: p.nationality,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
let bookingData: any;
|
||||
if (isAuthenticated) {
|
||||
// For authenticated users: get passengerId from multiple sources
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
|
||||
if (!token) {
|
||||
console.error('No token in localStorage');
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
// Save deviceId for future use
|
||||
if (typeof window !== 'undefined' && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
console.log('Token found, length:', token.length);
|
||||
|
||||
let passengerId = getPassengerIdFromToken(token);
|
||||
console.log('Extracted passenger ID from JWT token:', passengerId);
|
||||
|
||||
// Fallback 1: Use passengerId from booking store
|
||||
if (!passengerId && storedPassengerId) {
|
||||
passengerId = storedPassengerId;
|
||||
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
|
||||
}
|
||||
|
||||
// Fallback 2: Use passengerId from localStorage
|
||||
if (!passengerId && typeof window !== 'undefined') {
|
||||
const localStoragePassengerId = localStorage.getItem('booking_passengerId');
|
||||
if (localStoragePassengerId) {
|
||||
passengerId = localStoragePassengerId;
|
||||
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: Use passengerId from user object
|
||||
if (!passengerId && user) {
|
||||
passengerId = (user as any).passengerId;
|
||||
console.log('Fallback 3: Using passengerId from user object:', passengerId);
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
console.error('Failed to extract passengerId');
|
||||
console.error('User object:', user);
|
||||
console.error('User object keys:', user ? Object.keys(user) : 'null');
|
||||
console.error('Stored passengerId from booking store:', storedPassengerId);
|
||||
if (typeof window !== 'undefined') {
|
||||
console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId'));
|
||||
}
|
||||
throw new Error('Passenger ID not found in authentication token. Please log in again.');
|
||||
}
|
||||
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengerId: passengerId,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// For guests: send full passenger details array
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
@@ -182,11 +289,10 @@ export default function ReviewPage() {
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert('An unexpected error occurred. Please try again.');
|
||||
alert(error instanceof Error ? error.message : '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 (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
@@ -200,14 +306,11 @@ export default function ReviewPage() {
|
||||
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) => {
|
||||
// Get the fare per passenger from the schedule
|
||||
const farePerPassenger = selectedSchedule.baseFareAdult ||
|
||||
(selectedSchedule as any).fareAdult ||
|
||||
(selectedSchedule as any).price ||
|
||||
@@ -215,8 +318,6 @@ export default function ReviewPage() {
|
||||
|
||||
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);
|
||||
|
||||
@@ -298,7 +399,7 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +413,7 @@ export default function ReviewPage() {
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating Booking...' : 'Confirm & Pay'}
|
||||
{createBookingMutation.isPending ? 'Creating Booking...' : `Confirm ${isAuthenticated ? '' : '& Pay'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Key changes needed in review/page.tsx handleConfirm function:
|
||||
|
||||
const handleConfirm = async () => {
|
||||
console.log('handleConfirm called');
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
// Validate required data
|
||||
if (!seatHold?.holdId) {
|
||||
alert('Please select seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
alert('Missing search criteria. Please start over.');
|
||||
router.push('/booking/search');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
let bookingData: any;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// For authenticated users: get passengerId from user profile
|
||||
let passengerId = '';
|
||||
|
||||
try {
|
||||
// Fetch user's passenger profile
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id;
|
||||
console.log('Got passengerId from profile:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to get passenger profile:', error);
|
||||
throw new Error('Unable to retrieve your passenger profile. Please try again.');
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
throw new Error('Passenger profile not found. Please update your profile and try again.');
|
||||
}
|
||||
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengerId: passengerId,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// For guests: send full passenger details
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
console.log('Creating booking with payload:', bookingData);
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
@@ -30,6 +31,7 @@ export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
|
||||
const { data: stations, isLoading, error } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
@@ -49,6 +51,21 @@ export default function SearchPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Set user's nationality after component mounts and user data is available
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.nationality) {
|
||||
const normalized = user.nationality.toUpperCase().trim();
|
||||
console.log('User nationality from store:', user.nationality, 'Normalized:', normalized);
|
||||
if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') {
|
||||
setValue('nationality', 'DJIBOUTIAN');
|
||||
} else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') {
|
||||
setValue('nationality', 'ETHIOPIAN');
|
||||
} else {
|
||||
setValue('nationality', 'OTHER');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user?.nationality, setValue]);
|
||||
|
||||
// Restore previous search values from URL params
|
||||
useEffect(() => {
|
||||
const origin = searchParams.get('origin');
|
||||
@@ -286,6 +303,7 @@ export default function SearchPage() {
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label>
|
||||
<select {...register('nationality')} className="w-full px-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 text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
<option value="">Select nationality</option>
|
||||
<option value="ETHIOPIAN">Ethiopian</option>
|
||||
<option value="DJIBOUTIAN">Djiboutian</option>
|
||||
<option value="OTHER">Other</option>
|
||||
|
||||
@@ -6,10 +6,36 @@ import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
|
||||
// Separate component for seat button to prevent re-render issues
|
||||
const SeatButton = memo(({ seat, isSelected, onToggle }: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={seat.status !== 'AVAILABLE'}
|
||||
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary text-white shadow-md scale-105'
|
||||
: seat.status === 'AVAILABLE'
|
||||
? '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 cursor-pointer'
|
||||
: seat.status === 'HELD'
|
||||
? '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}`}
|
||||
>
|
||||
{seatLabel}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
SeatButton.displayName = 'SeatButton';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
|
||||
@@ -29,20 +55,10 @@ export default function SeatsPage() {
|
||||
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 as any)?.coaches);
|
||||
}
|
||||
}, [seatMapData]);
|
||||
|
||||
const holdMutation = useMutation({
|
||||
mutationFn: async (seatIds: string[]) => {
|
||||
// Create temporary passenger IDs for the hold
|
||||
const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({
|
||||
passengerId: `temp-${Date.now()}-${i}`, // Temporary ID for guest booking
|
||||
passengerId: `temp-${Date.now()}-${i}`,
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
|
||||
@@ -61,47 +77,21 @@ export default function SeatsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Extract coaches and seats from seat map data
|
||||
const coaches = (seatMapData as any)?.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);
|
||||
}
|
||||
const filteredCoaches = useMemo(() => {
|
||||
return selectedSchedule?.selectedSeatClass
|
||||
? coaches.filter((c: any) => {
|
||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||
})
|
||||
: coaches;
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
|
||||
// Filter coaches by selected seat class if available
|
||||
const filteredCoaches = 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]);
|
||||
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
|
||||
const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
||||
@@ -109,13 +99,16 @@ export default function SeatsPage() {
|
||||
}
|
||||
}, [filteredCoaches, selectedCoach]);
|
||||
|
||||
const toggleSeat = (seatId: string) => {
|
||||
if (selectedSeats.includes(seatId)) {
|
||||
setSelectedSeats(selectedSeats.filter(id => id !== seatId));
|
||||
} else if (selectedSeats.length < passengers.length) {
|
||||
setSelectedSeats([...selectedSeats, seatId]);
|
||||
}
|
||||
};
|
||||
const toggleSeat = useCallback((seatId: string) => {
|
||||
setSelectedSeats(prev => {
|
||||
if (prev.includes(seatId)) {
|
||||
return prev.filter(id => id !== seatId);
|
||||
} else if (prev.length < passengers.length) {
|
||||
return [...prev, seatId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [passengers.length]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (selectedSeats.length > 0) {
|
||||
@@ -235,28 +228,14 @@ export default function SeatsPage() {
|
||||
{/* Seat Grid */}
|
||||
<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 || '?';
|
||||
return (
|
||||
<button
|
||||
key={seat.id}
|
||||
onClick={() => seat.status === 'AVAILABLE' && toggleSeat(seat.id)}
|
||||
disabled={seat.status !== 'AVAILABLE'}
|
||||
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${
|
||||
selectedSeats.includes(seat.id)
|
||||
? 'bg-primary text-white shadow-md scale-105'
|
||||
: seat.status === 'AVAILABLE'
|
||||
? '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 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}`}
|
||||
>
|
||||
{seatLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{seats?.map((seat: any) => (
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={selectedSeats.includes(seat.id)}
|
||||
onToggle={toggleSeat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,12 +94,7 @@ function LoginContent() {
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
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);
|
||||
}}
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
>
|
||||
← Back to booking
|
||||
|
||||
@@ -7,11 +7,22 @@ interface User {
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
passengerId?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
nationalityCode?: string;
|
||||
nationalId?: string;
|
||||
passportNumber?: string;
|
||||
passportCountry?: string;
|
||||
passportIssueDate?: string;
|
||||
passportExpiryDate?: string;
|
||||
passportIssuingAuthority?: string;
|
||||
faydaVerified?: boolean;
|
||||
faydaSub?: string;
|
||||
faydaVerifiedAt?: string;
|
||||
lastLoginAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -58,6 +58,7 @@ interface BookingState {
|
||||
pnr: string | null;
|
||||
selectedPaymentMethod: string | null;
|
||||
createAccount: boolean;
|
||||
passengerId: string | null;
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
@@ -67,11 +68,12 @@ interface BookingState {
|
||||
setPNR: (pnr: string) => void;
|
||||
setPaymentMethod: (method: string) => void;
|
||||
setCreateAccount: (create: boolean) => void;
|
||||
setPassengerId: (id: string | null) => void;
|
||||
clearBooking: () => void;
|
||||
}
|
||||
|
||||
export const useBookingStore = create<BookingState>()(persist(
|
||||
(set) => ({
|
||||
(set) => (({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
passengers: [],
|
||||
@@ -80,6 +82,7 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
pnr: null,
|
||||
selectedPaymentMethod: null,
|
||||
createAccount: false,
|
||||
passengerId: null,
|
||||
|
||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||
@@ -89,6 +92,7 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
setPNR: (pnr) => set({ pnr }),
|
||||
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
|
||||
setCreateAccount: (create) => set({ createAccount: create }),
|
||||
setPassengerId: (id) => set({ passengerId: id }),
|
||||
clearBooking: () => set({
|
||||
searchCriteria: null,
|
||||
selectedSchedule: null,
|
||||
@@ -98,8 +102,9 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
pnr: null,
|
||||
selectedPaymentMethod: null,
|
||||
createAccount: false,
|
||||
passengerId: null,
|
||||
}),
|
||||
}),
|
||||
} as BookingState)),
|
||||
{
|
||||
name: 'booking-storage',
|
||||
storage: createJSONStorage(() => {
|
||||
|
||||
470
implementation-guide.md
Normal file
470
implementation-guide.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Implementation Guide: User Profile & Booking Flow Update
|
||||
|
||||
## Overview
|
||||
- **Auto-populate**: Logged-in users see their profile details pre-filled in passenger form
|
||||
- **View Bookings**: New endpoint to retrieve user's booking history
|
||||
- **Duplicate Allowed**: Passenger details always saved as NEW records for each booking
|
||||
- **No PassengerId Dependency**: Remove JWT passengerId encoding, fetch from profile when needed
|
||||
|
||||
---
|
||||
|
||||
## BACKEND CHANGES (edr-passenger-api)
|
||||
|
||||
### 1. Bookings Service - Add findByPassengerId Method
|
||||
**File**: `src/modules/bookings/bookings.service.ts`
|
||||
|
||||
Add method after `findAll()`:
|
||||
```typescript
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = { passengerId };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
|
||||
paymentIntent: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
number: booking.schedule.train.number,
|
||||
origin: booking.schedule.originStation,
|
||||
destination: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
passengers: booking.seats.map(s => ({
|
||||
name: s.passengerName,
|
||||
category: s.passengerCategory,
|
||||
seat: {
|
||||
number: s.seat.label,
|
||||
coach: s.seat.coach.label,
|
||||
class: s.seat.coach.seatClass.name,
|
||||
},
|
||||
})),
|
||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Bookings Controller - Add Endpoint for User Bookings
|
||||
**File**: `src/modules/bookings/bookings.controller.ts`
|
||||
|
||||
Add imports:
|
||||
```typescript
|
||||
import { Request, UnauthorizedException } from '@nestjs/common';
|
||||
```
|
||||
|
||||
Add method before the `create()` method:
|
||||
```typescript
|
||||
@Get('my/bookings')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user bookings',
|
||||
description: 'Retrieve all bookings made by the logged-in user with search and filter options'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or passenger name' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' })
|
||||
getMyBookings(
|
||||
@Request() req: any,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const passengerId = req.user?.passengerId;
|
||||
if (!passengerId) {
|
||||
throw new UnauthorizedException('Passenger ID not found in token');
|
||||
}
|
||||
return this.service.findByPassengerId(passengerId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Auth Service - OPTIONAL: Remove passengerId from JWT
|
||||
**File**: `src/modules/auth/auth.service.ts`
|
||||
|
||||
If you want to remove passengerId from JWT (recommended for security):
|
||||
- Update `signToken()` to NOT include passengerId in payload
|
||||
- Users will fetch it from `/passengers/me` when needed
|
||||
|
||||
```typescript
|
||||
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, fullName: true, role: true }
|
||||
});
|
||||
|
||||
// Don't include passengerId in JWT - fetch from profile instead
|
||||
const token = this.jwt.sign({ sub: userId, email, role, agentId });
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
email,
|
||||
fullName: user?.fullName || email,
|
||||
role,
|
||||
agentId
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FRONTEND CHANGES (edr-passenger-web/portal)
|
||||
|
||||
### 1. Booking Store - Remove passengerId
|
||||
**File**: `src/lib/booking-store.ts`
|
||||
|
||||
Remove:
|
||||
```typescript
|
||||
passengerId: string | null;
|
||||
setPassengerId: (id: string | null) => void;
|
||||
```
|
||||
|
||||
### 2. Passengers Page - Update onSubmit
|
||||
**File**: `src/app/booking/passengers/page.tsx`
|
||||
|
||||
Replace the `onSubmit` function:
|
||||
```typescript
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Build passenger details - ALWAYS create new records
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
gender: p.gender,
|
||||
nationality: p.nationality,
|
||||
nationalId: p.nationalId,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
}));
|
||||
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Review Page - Get passengerId from Profile
|
||||
**File**: `src/app/booking/review/page.tsx`
|
||||
|
||||
Replace the `handleConfirm` function to fetch passengerId:
|
||||
```typescript
|
||||
const handleConfirm = async () => {
|
||||
console.log('handleConfirm called');
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
if (!seatHold?.holdId) {
|
||||
alert('Please select seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
alert('Missing search criteria. Please start over.');
|
||||
router.push('/booking/search');
|
||||
return;
|
||||
}
|
||||
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
let bookingData: any;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Fetch passengerId from user profile
|
||||
let passengerId = '';
|
||||
|
||||
try {
|
||||
const passengerProfile: any = await apiClient.get('/passengers/me');
|
||||
passengerId = passengerProfile?.id;
|
||||
console.log('Got passengerId from profile:', passengerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to get passenger profile:', error);
|
||||
throw new Error('Unable to retrieve your passenger profile. Please try again.');
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
throw new Error('Passenger profile not found. Please update your profile and try again.');
|
||||
}
|
||||
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengerId: passengerId,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Guest booking
|
||||
bookingData = {
|
||||
scheduleId: selectedSchedule?.id || '',
|
||||
holdId: seatHold.holdId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB',
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||
nationality: p.nationality,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
console.log('Creating booking with payload:', bookingData);
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Create New Bookings List Component
|
||||
**File**: `src/app/bookings/page.tsx` (new file)
|
||||
|
||||
Create a page to display user's bookings:
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function BookingsPage() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const [bookings, setBookings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({ status: '', search: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
const response: any = await apiClient.get('/bookings/my/bookings', {
|
||||
params: filters,
|
||||
});
|
||||
setBookings(response.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bookings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBookings();
|
||||
}, [isAuthenticated, filters]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div className="p-4">Please log in to view your bookings.</div>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading bookings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold mb-6">My Bookings</h1>
|
||||
|
||||
<div className="mb-6 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bookings..."
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="input-field flex-1"
|
||||
/>
|
||||
<select
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
<option value="PENDING_PAYMENT">Pending</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<div className="card text-center">
|
||||
<p className="text-gray-600">No bookings found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{bookings.map((booking) => (
|
||||
<div key={booking.id} className="card">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{booking.bookingRef}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{booking.schedule.origin.name} → {booking.schedule.destination.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{format(new Date(booking.schedule.departureAt), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-semibold ${booking.status === 'CONFIRMED' ? 'text-green-600' : 'text-gray-600'}`}>
|
||||
{booking.status}
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{booking.displayCurrency} {(booking.displayTotalMinor / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FLOW SUMMARY
|
||||
|
||||
### Logged-in User Booking Flow:
|
||||
1. User logs in → Profile data loaded
|
||||
2. Search for trip → Select passengers
|
||||
3. **Passengers Page**: Auto-populate first passenger from profile, allow editing
|
||||
4. **Seats Page**: Select seats
|
||||
5. **Review Page**: Fetch latest passengerId from `/passengers/me`, create booking
|
||||
6. **Confirmation**: Booking created with new passenger details
|
||||
|
||||
### View Bookings:
|
||||
1. User clicks "My Bookings" → `/bookings/my/bookings` endpoint
|
||||
2. Returns all bookings for that passengerId with details
|
||||
|
||||
### Duplicate Passengers:
|
||||
- Each booking creates NEW passenger records in BookingSeat table
|
||||
- No constraints on duplicate data
|
||||
- Allows flexibility for user changes
|
||||
|
||||
---
|
||||
|
||||
## Key Differences from Previous Implementation
|
||||
|
||||
| Aspect | Old | New |
|
||||
|--------|-----|-----|
|
||||
| **PassengerId Storage** | Encoded in JWT | Fetched from `/passengers/me` profile |
|
||||
| **Passenger Details** | Linked to user profile | NEW copy created for each booking |
|
||||
| **View Bookings** | N/A | New `/bookings/my/bookings` endpoint |
|
||||
| **Auto-populate** | Limited | Complete profile details |
|
||||
| **Duplicates** | Not allowed | Always allowed and encouraged |
|
||||
| **Frontend Logic** | Complex passengerId fallbacks | Simple profile fetch |
|
||||
|
||||
Reference in New Issue
Block a user