diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 35dc77441..d85b17dbe 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -22,7 +22,7 @@ async function bootstrap() { new ResponseTransformInterceptor(), app.get(SessionActivityInterceptor), ); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false })); const config = new DocumentBuilder() .setTitle("EDR Passenger API") diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index a1a17365a..6a7c13faf 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -219,21 +219,41 @@ The API automatically detects: @Post('save-details') @ApiOperation({ - summary: '[LEGACY] Save all passenger details before seat selection', - description: `**Note:** This endpoint is legacy. Consider using \`POST /passengers/register\` instead. + summary: 'Bulk save passenger details from booking flow', + description: `**Endpoint for saving multiple passengers in a single booking** -Saves all passenger details to database before proceeding to seat selection. +--- -- Required step in booking flow -- Saves details for all passengers in booking -- Supports both logged-in users and guests -- Prevents data loss if user navigates away +### Purpose +Save all passenger details for a multi-passenger booking before proceeding to seat selection. Optimized for batch operations where all passengers are collected upfront. -**Migration:** Use \`POST /passengers/register\` for new implementations.`, +--- + +### Use Cases +1. **Multi-passenger bookings** - Save all passengers in a single request +2. **Batch registration** - Admin/Agent registering multiple passengers at once +3. **Data preservation** - Save passenger data before proceeding to seat selection +4. **Guest bookings** - Multiple guests booking together + +--- + +### Differences from /register +| Feature | /register | /save-details | +|---------|-----------|---------------| +| Purpose | Single passenger registration with optional verification | Bulk save multiple passengers | +| Passengers | One at a time | Multiple in array | +| Verification | Auto-attempts for Ethiopian nationals (if enabled) | No automatic verification | +| Use case | Individual registration flow | Booking flow with all passengers | +| Authentication | Optional JWT | Optional JWT | + +--- + +### Response +Returns saved passenger details with generated IDs and confirmation.`, }) @ApiResponse({ status: 201, - description: 'Passenger details saved successfully', + description: 'All passenger details saved successfully', schema: { example: { count: 2, @@ -241,15 +261,17 @@ Saves all passenger details to database before proceeding to seat selection. passengers: [ { id: 'uuid-1', - passengerName: 'John Doe', - dateOfBirth: '1990-01-01T00:00:00.000Z', - nationality: 'Ethiopian' + passengerName: 'Abebe Kebede', + dateOfBirth: '1985-03-15T00:00:00.000Z', + nationality: 'Ethiopian', + nationalId: 'ET123456789' }, { id: 'uuid-2', - passengerName: 'Jane Doe', - dateOfBirth: '1992-05-15T00:00:00.000Z', - nationality: 'Ethiopian' + passengerName: 'Sara Ketsela', + dateOfBirth: '1990-08-22T00:00:00.000Z', + nationality: 'Ethiopian', + nationalId: 'ET987654321' } ], message: 'Passenger details saved successfully' @@ -257,9 +279,8 @@ Saves all passenger details to database before proceeding to seat selection. } }) @ApiResponse({ status: 400, description: 'Validation error - passengers array required' }) - savePassengers(@Body() body: any) { - const passengers = body.passengers || (Array.isArray(body) ? body : [body]); - return this.service.savePassengers(passengers, body.userId, body.deviceId); + savePassengers(@Body() dto: SavePassengersDto) { + return this.service.savePassengers(dto.passengers, dto.userId, dto.deviceId); } @Post('traveler-profiles') diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts index 4a48cb8b5..d4955db55 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator'; +import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateTravelerProfileDto { @@ -76,51 +77,60 @@ export class RegisterInternationalPassengerDto { } export class SavePassengerDetailsDto { - @ApiProperty({ example: 'Abebe Kebede' }) + @ApiProperty({ example: 'Abebe Kebede', description: 'Full name of passenger' }) @IsString() name: string; - @ApiProperty({ example: '1985-03-15' }) + @ApiProperty({ example: '1985-03-15', description: 'Date of birth in ISO format YYYY-MM-DD' }) @IsDateString() dateOfBirth: string; - @ApiProperty({ example: 'ETHIOPIAN' }) - @IsString() - nationality: string; - - @ApiPropertyOptional({ example: 'ET123456789' }) + @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID (for Ethiopian passengers)' }) @IsOptional() @IsString() nationalId?: string; - @ApiPropertyOptional({ example: 'P1234567' }) + @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number (for international passengers)' }) @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional({ example: 'Kenya' }) + @ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' }) @IsOptional() @IsString() passportCountry?: string; - @ApiPropertyOptional({ example: '+251911234567' }) + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality' }) + @IsOptional() + @IsString() + nationality?: string; + + @ApiPropertyOptional({ example: '+251911234567', description: 'Phone number' }) @IsOptional() @IsString() phone?: string; - @ApiPropertyOptional({ example: 'email@example.com' }) + @ApiPropertyOptional({ example: 'abebe@example.com', description: 'Email address' }) @IsOptional() @IsString() email?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ example: 'Male', description: 'Gender' }) @IsOptional() @IsString() - faydaSub?: string; + gender?: string; + + @ApiPropertyOptional({ example: true, description: 'Whether this is the primary passenger' }) + @IsOptional() + @IsBoolean() + isPrimaryPassenger?: boolean; } export class SavePassengersDto { @ApiProperty({ type: [SavePassengerDetailsDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SavePassengerDetailsDto) passengers: SavePassengerDetailsDto[]; @ApiPropertyOptional({ description: 'User ID if logged in' }) diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 2d839cb6e..b99ced226 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -145,7 +145,7 @@ export class PassengersService { data: { userId, deviceId, - passengerName: p.name, + passengerName: p.name || p.passengerName, dateOfBirth: new Date(p.dateOfBirth), idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT', passportNumber: p.passportNumber, diff --git a/apps/edr-passenger-web/backoffice/.env.example b/apps/edr-passenger-web/backoffice/.env.example index 17f2c04e5..5263b3a36 100644 --- a/apps/edr-passenger-web/backoffice/.env.example +++ b/apps/edr-passenger-web/backoffice/.env.example @@ -1,6 +1,9 @@ # API Configuration -NEXT_PUBLIC_API_URL=http://localhost:3002 +NEXT_PUBLIC_API_URL=https://your-api-domain.com # IAM Configuration (Corporate Authentication) NEXT_PUBLIC_IAM_ENABLED=false NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api + +# GitHub Packages Token +GITHUB_PACKAGE_TOKEN=$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 diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 962f3ca54..d914f0f6f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -92,7 +92,6 @@ export default function SchedulesPage() { const departureAt = formData.get('departureAt') as string; const arrivalAt = formData.get('arrivalAt') as string; - // Convert datetime-local to ISO 8601 const departureISO = new Date(departureAt).toISOString(); const arrivalISO = new Date(arrivalAt).toISOString(); @@ -101,7 +100,7 @@ export default function SchedulesPage() { routeId: formData.get('routeId') as string, departureAt: departureISO, arrivalAt: arrivalISO, - plannedTimes: [], // Will be auto-generated by backend based on route stops + plannedTimes: [], }; if (editingSchedule) { diff --git a/apps/edr-passenger-web/backoffice/src/lib/utils.ts b/apps/edr-passenger-web/backoffice/src/lib/utils.ts index f1d91d0bb..249ffcef2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/utils.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/utils.ts @@ -16,6 +16,10 @@ export const formatDateTime = (date: string | Date): string => { return format(new Date(date), 'MMM dd, yyyy HH:mm'); }; +export const formatDateTimeLocal = (date: string | Date): string => { + return format(new Date(date), 'MMM dd, yyyy HH:mm'); +}; + export const getStatusColor = (status: string): string => { const colors: Record = { CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400', diff --git a/apps/edr-passenger-web/portal/.env.example b/apps/edr-passenger-web/portal/.env.example index ad25284c7..25ffe6909 100644 --- a/apps/edr-passenger-web/portal/.env.example +++ b/apps/edr-passenger-web/portal/.env.example @@ -1 +1,5 @@ -NEXT_PUBLIC_API_URL=http://localhost:3002 +# API Configuration +NEXT_PUBLIC_API_URL=https://your-api-domain.com + +# GitHub Packages Token +GITHUB_PACKAGE_TOKEN=$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 \ No newline at end of file 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 6eb667f4b..2b2aa3e40 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 @@ -112,68 +112,55 @@ export default function PassengersPage() { }, [isAuthenticated, user, searchCriteria, setValue]); const openFaydaVerification = async (index: number) => { - if (typeof window === 'undefined') return; // Guard for SSR + if (typeof window === 'undefined') return; - const faydaUrl = process.env.NEXT_PUBLIC_FAYDA_URL || 'https://fayda.gov.et/verify'; - const callbackUrl = `${window.location.origin}/booking/passengers?faydaCallback=${index}`; - const width = 600; - const height = 700; - const left = (window.screen.width - width) / 2; - const top = (window.screen.height - height) / 2; - - window.open( - `${faydaUrl}?callback=${encodeURIComponent(callbackUrl)}`, - 'FaydaVerification', - `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes` - ); + try { + const response: any = await apiClient.post('/fayda/verification/start', { + purpose: 'PURCHASE', + platform: 'WEB', + saveToAccount: index === 0 && isAuthenticated, + }); - const handleMessage = async (event: MessageEvent) => { - if (event.data?.type === 'FAYDA_VERIFIED' && event.data?.index === index) { - const data = event.data.passengerData; - setValue(`passengers.${index}.name`, data.fullName); - setValue(`passengers.${index}.dateOfBirth`, data.dateOfBirth.split('T')[0]); - setValue(`passengers.${index}.gender`, data.gender); - setValue(`passengers.${index}.nationality`, data.nationality || 'ETHIOPIAN'); - setValue(`passengers.${index}.phone`, data.phone || ''); - setValue(`passengers.${index}.email`, data.email || ''); - setValue(`passengers.${index}.faydaVerified`, true); - setValue(`passengers.${index}.faydaSub`, data.faydaSub); - setValue(`passengers.${index}.formExpanded`, true); - setVerificationStatus({ ...verificationStatus, [index]: 'success' }); + const authorizationUrl = response.authorizationUrl; + const width = 600; + const height = 700; + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + + const popup = window.open( + authorizationUrl, + 'FaydaVerification', + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes` + ); - if (index === 0 && isAuthenticated && user) { - setUpdatingUser(true); + const checkPopup = setInterval(async () => { + if (popup?.closed) { + clearInterval(checkPopup); try { - await apiClient.patch('/auth/profile', { - fullName: data.fullName, - dateOfBirth: data.dateOfBirth, - gender: data.gender, - nationality: data.nationality || 'ETHIOPIAN', - phone: data.phone, - faydaVerified: true, - faydaSub: data.faydaSub, - }); - updateUser({ - fullName: data.fullName, - dateOfBirth: data.dateOfBirth.split('T')[0], - gender: data.gender, - nationality: data.nationality || 'ETHIOPIAN', - phone: data.phone, - faydaVerified: true, - faydaSub: data.faydaSub, - }); + const statusResponse: any = await apiClient.get('/fayda/verification/status'); + if (statusResponse.verified) { + setValue(`passengers.${index}.name`, statusResponse.fullName || ''); + setValue(`passengers.${index}.faydaVerified`, true); + setValue(`passengers.${index}.formExpanded`, true); + setVerificationStatus({ ...verificationStatus, [index]: 'success' }); + + if (index === 0 && isAuthenticated) { + updateUser({ + fullName: statusResponse.fullName, + faydaVerified: true, + faydaVerifiedAt: statusResponse.verifiedAt, + }); + } + } } catch (error) { - console.error('Failed to update user profile:', error); - } finally { - setUpdatingUser(false); + console.error('Failed to get verification status:', error); } } - - window.removeEventListener('message', handleMessage); - } - }; - - window.addEventListener('message', handleMessage); + }, 1000); + } catch (error) { + console.error('Failed to start Fayda verification:', error); + alert('Failed to start verification. Please try again.'); + } }; const toggleForm = (index: number) => { @@ -184,11 +171,18 @@ export default function PassengersPage() { setSaving(true); try { const passengerDetails = data.passengers.map((p, i) => ({ - ...p, + 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, })); - // Save passenger details to database before proceeding const deviceId = typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || crypto.randomUUID()) : crypto.randomUUID(); @@ -271,7 +265,7 @@ export default function PassengersPage() { diff --git a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts index aad31a4c5..02eea57c6 100644 --- a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts +++ b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts @@ -120,7 +120,7 @@ export function dayOfYearToDate(year: number, dayOfYear: number): Date { */ export function formatEthiopianDate(ethDate: EthiopianDate): string { const monthName = ETHIOPIAN_MONTHS[ethDate.month - 1] || 'Unknown'; - return `${ethDate.day} ${monthName} ${ethDate.year}`; + return `${monthName} ${ethDate.day}, ${ethDate.year}`; } /** diff --git a/apps/edr-passenger-web/portal/src/utils/format.ts b/apps/edr-passenger-web/portal/src/utils/format.ts new file mode 100644 index 000000000..27ab7db59 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/utils/format.ts @@ -0,0 +1,26 @@ +import { format, toZonedTime } from 'date-fns-tz'; + +const ADDIS_TZ = 'Africa/Addis_Ababa'; + +export const formatCurrency = (amount: number, currency: string = 'ETB'): string => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 2, + }).format(amount / 100); +}; + +export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { + const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); + return format(zonedDate, formatStr, { timeZone: ADDIS_TZ }); +}; + +export const formatDateTime = (date: string | Date): string => { + const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); + return format(zonedDate, 'MMM dd, yyyy HH:mm', { timeZone: ADDIS_TZ }); +}; + +export const formatTime = (date: string | Date): string => { + const zonedDate = toZonedTime(new Date(date), ADDIS_TZ); + return format(zonedDate, 'HH:mm', { timeZone: ADDIS_TZ }); +}; diff --git a/apps/edr-passenger-web/portal/tsconfig.json b/apps/edr-passenger-web/portal/tsconfig.json index aeb89f694..eef2e2933 100644 --- a/apps/edr-passenger-web/portal/tsconfig.json +++ b/apps/edr-passenger-web/portal/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "../../../packages/config/tsconfig/nextjs.json", "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": [ "./src/*" + ], + "*": [ + "./*" ] }, "plugins": [