mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ coverage/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.npmrc
|
.npmrc
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
"@sendgrid/mail": "^8.1.0",
|
"@sendgrid/mail": "^8.1.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -44,8 +45,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0"
|
||||||
"@prisma/client": "^6.19.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.19",
|
"@nestjs/testing": "^11.1.19",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -113,8 +113,19 @@ async function main() {
|
|||||||
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
|
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
|
||||||
if (existingSchedules.length > 0) {
|
if (existingSchedules.length > 0) {
|
||||||
const scheduleIds = existingSchedules.map(s => s.id);
|
const scheduleIds = existingSchedules.map(s => s.id);
|
||||||
// Delete in correct order to avoid foreign key constraints
|
const bookingIds = (
|
||||||
await prisma.bookingSeat.deleteMany({ where: { booking: { scheduleId: { in: scheduleIds } } } });
|
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
|
||||||
|
).map(b => b.id);
|
||||||
|
// Delete booking children in FK-safe order before deleting the bookings themselves
|
||||||
|
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
|
||||||
|
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
|
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||||
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
||||||
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
|
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
|
||||||
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
|
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { RolesGuard } from '../../common/roles.guard';
|
import { RolesGuard } from '../../common/roles.guard';
|
||||||
import { Roles } from '../../common/roles.decorator';
|
import { Roles } from '../../common/roles.decorator';
|
||||||
@@ -62,4 +63,113 @@ export class PaymentsController {
|
|||||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
||||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
||||||
|
|
||||||
|
@Get('checkout')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Browser checkout redirect',
|
||||||
|
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'bookingId', required: true })
|
||||||
|
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
||||||
|
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
||||||
|
@ApiProduces('text/html')
|
||||||
|
async checkout(
|
||||||
|
@Query('bookingId') bookingId: string,
|
||||||
|
@Query('method') method: PaymentMethodTypeEnum,
|
||||||
|
@Query('platform') platform: PaymentPlatformDto = 'web',
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (!bookingId) {
|
||||||
|
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId'));
|
||||||
|
}
|
||||||
|
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||||
|
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
||||||
|
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
||||||
|
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRedirectHtml(url: string): string {
|
||||||
|
const escaped = url.replace(/"/g, '"');
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||||
|
<title>Redirecting to payment…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
p { color: #555; margin: 0 0 16px; }
|
||||||
|
a { color: #1a73e8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Redirecting to payment provider…</p>
|
||||||
|
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||||
|
</div>
|
||||||
|
<script>window.location.href = "${escaped}";</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatusHtml(status: string, intentId: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment status</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||||
|
small { color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">${status}</div>
|
||||||
|
<small>Intent: ${intentId}</small>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorHtml(message: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="error">Payment could not be initiated</div>
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: 'Checkout' as const,
|
trade_type: 'Checkout' as const,
|
||||||
title: `EDR Booking ${input.bookingRef}`,
|
title: `EDR Booking`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
output: 'export',
|
||||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ export default function ConfirmationPage() {
|
|||||||
router.push('/booking/search');
|
router.push('/booking/search');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!bookingId || !pnr) {
|
useEffect(() => {
|
||||||
router.push('/booking/search');
|
if (!bookingId || !pnr) {
|
||||||
return null;
|
router.push('/booking/search');
|
||||||
}
|
}
|
||||||
|
}, [bookingId, pnr, router]);
|
||||||
|
|
||||||
|
if (!bookingId || !pnr) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { useForm, useFieldArray } from 'react-hook-form';
|
import { useForm, useFieldArray } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -282,10 +280,13 @@ export default function PassengersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!searchCriteria) {
|
useEffect(() => {
|
||||||
router.push('/booking/search');
|
if (!searchCriteria) {
|
||||||
return null;
|
router.push('/booking/search');
|
||||||
}
|
}
|
||||||
|
}, [searchCriteria, router]);
|
||||||
|
|
||||||
|
if (!searchCriteria) return null;
|
||||||
|
|
||||||
if (nationalityMismatch && formInitialized) {
|
if (nationalityMismatch && formInitialized) {
|
||||||
const searchLabel: Record<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };
|
const searchLabel: Record<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };
|
||||||
|
|||||||
@@ -156,10 +156,13 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!selectedSchedule || !passengers.length) {
|
useEffect(() => {
|
||||||
router.push('/booking/search');
|
if (!selectedSchedule || !passengers.length) {
|
||||||
return null;
|
router.push('/booking/search');
|
||||||
}
|
}
|
||||||
|
}, [selectedSchedule, passengers.length, router]);
|
||||||
|
|
||||||
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,470 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
|
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -226,6 +226,9 @@ importers:
|
|||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^5.0.6
|
||||||
|
version: 5.0.6
|
||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: ^29.5.11
|
specifier: ^29.5.11
|
||||||
version: 29.5.14
|
version: 29.5.14
|
||||||
|
|||||||
Reference in New Issue
Block a user