Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-16 22:50:01 +00:00
29 changed files with 1202 additions and 659 deletions

View File

@@ -1,243 +0,0 @@
name: PolinRider Malware Scan
# ── Triggers ──────────────────────────────────────────────────────────────────
# Runs on every push and every PR targeting main/master/develop.
# Also available as a manual trigger (workflow_dispatch) and on a nightly
# schedule so dormant infections in older branches are caught too.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
# Nightly full-repo scan at 02:00 UTC
- cron: "0 2 * * *"
workflow_dispatch:
# ── Permissions ───────────────────────────────────────────────────────────────
permissions:
contents: read # checkout
security-events: write # upload SARIF to GitHub Security tab
actions: read
checks: write # annotate PRs with scan findings
# ── Deployment gate ───────────────────────────────────────────────────────────
# All other jobs (build, test, deploy) should list this job under `needs:`.
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
jobs:
polinrider-scan:
name: "PolinRider / Famous Chollima Scan"
runs-on: ubuntu-latest
# Prevent CI from being disabled by any workflow override
if: always()
steps:
# ── 1. Checkout full history ─────────────────────────────────────────────
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── 2. Detect suspicious force-push patterns in git history ──────────────
- name: Check git history for force-push and timestamp manipulation
id: git-check
shell: bash
run: |
echo "=== Checking for suspicious git history patterns ==="
# Check for .gitignore entries hiding known malware artifacts
GITIGNORE_HITS=0
if [ -f .gitignore ]; then
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
if grep -qF "$pattern" .gitignore 2>/dev/null; then
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
fi
done
fi
# Check if malware persistence artifacts exist anywhere in the tree
ARTIFACTS_FOUND=0
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
if [ -n "$FOUND" ]; then
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
echo "$FOUND"
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
fi
done
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
if [ -n "$NO_VERIFY_COMMITS" ]; then
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
echo "$NO_VERIFY_COMMITS"
fi
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
done
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
- name: Run PolinRider malware scanner
id: scanner
shell: bash
run: |
echo "=== Running PolinRider IOC scanner ==="
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
node .github/scripts/scan.js \
--json \
--output scan-report.json \
.
SCANNER_EXIT=$?
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
# Also emit a human-readable summary to the Actions log
node .github/scripts/scan.js . || true
exit $SCANNER_EXIT
# ── 4. Upload scan report as artifact ────────────────────────────────────
# - name: Upload scan report
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: polinrider-scan-report
# path: scan-report.json
# retention-days: 90
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
# - name: Convert scan results to SARIF
# if: always()
# shell: bash
# run: |
# node - << 'SCRIPT'
# const fs = require('fs');
# let report;
# try {
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
# } catch {
# // No report = no findings, write empty SARIF
# report = { results: [] };
# }
# const severityMap = {
# CRITICAL: 'error',
# HIGH: 'warning',
# MEDIUM: 'note',
# };
# const sarif = {
# version: '2.1.0',
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
# runs: [{
# tool: {
# driver: {
# name: 'PolinRider Malware Scanner',
# version: '1.0.0',
# informationUri: 'https://github.com/your-org/your-repo',
# rules: [
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
# shortDescription: { text: "global['!'] campaign marker" } },
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
# shortDescription: { text: 'rmcej%otb% seed string' } },
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
# { id: 'POLINRIDER-005', name: 'TRONWallet',
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
# { id: 'POLINRIDER-006', name: 'AptosAddress',
# shortDescription: { text: 'Known Aptos dead-drop address' } },
# { id: 'POLINRIDER-007', name: 'XORKey',
# shortDescription: { text: 'Known XOR decryption key' } },
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
# shortDescription: { text: 'Duplicate createRequire injection' } },
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
# shortDescription: { text: 'Config file size anomaly' } },
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
# shortDescription: { text: 'PolinRider persistence artifact present' } },
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
# shortDescription: { text: 'Numeric campaign marker pattern' } },
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
# shortDescription: { text: 'sfL obfuscation function' } },
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
# shortDescription: { text: 'global require/module injection' } },
# ],
# },
# },
# results: (report.results || []).flatMap(file =>
# (file.findings || []).map(finding => ({
# ruleId: finding.id,
# level: severityMap[finding.severity] || 'warning',
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
# locations: [{
# physicalLocation: {
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
# region: { startLine: 1 },
# },
# }],
# }))
# ),
# }],
# };
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
# console.log('SARIF written.');
# SCRIPT
# - name: Upload SARIF to GitHub Security tab
# if: always()
# uses: github/codeql-action/upload-sarif@v3
# with:
# sarif_file: scan-results.sarif
# category: polinrider-malware-scan
# ── 6. Block deployment if infected ──────────────────────────────────────
- name: Enforce clean-scan gate
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
shell: bash
run: |
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
echo "║ ║"
echo "║ This repository contains code signatures consistent with the ║"
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
echo "║ ║"
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
echo "║ ║"
echo "║ See scan-report.json artifact for full details. ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
exit 1
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
# Example: your existing build/deploy jobs should look like this:
#
# build:
# needs: polinrider-scan
# runs-on: ubuntu-latest
# steps:
# ...
#
# deploy:
# needs: [polinrider-scan, build]
# ...

View File

@@ -14,6 +14,10 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000
UPDATE freight.weight_limit_rules
SET trade_direction = 'BOTH'
WHERE trade_direction::text = 'ANY';
UPDATE freight.weight_limit_rules
SET trade_direction = 'IMPORT'
WHERE trade_direction IS NULL;
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
END $$;
`);

View File

@@ -1,5 +1,6 @@
import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
@@ -16,7 +17,7 @@ type RetriableRequest = {
};
const api = axios.create({
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
baseURL: `${API_BASE_URL}/api`,
withCredentials: true,
});

View File

@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo {
id: string;
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoFormDialog({
open,
onOpenChange,

View File

@@ -0,0 +1 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';

View File

@@ -1,23 +1,36 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { IncomingMessage, ServerResponse } from "node:http";
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import type { ViteDevServer, PreviewServer } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function userManagementSpaFallback() {
const rewrite = (req) => {
const url = req.url || '';
const rewrite = (req: IncomingMessage) => {
const url = req.url ?? '';
if (!url.startsWith('/_um/') && url !== '/_um') return;
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
req.url = '/_um/index.html';
};
return {
name: 'user-management-spa-fallback',
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
configureServer(s: ViteDevServer) {
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
rewrite(req);
next();
});
},
configurePreviewServer(s: PreviewServer) {
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
rewrite(req);
next();
});
},
};
}
@@ -29,6 +42,11 @@ export default defineConfig({
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
},
// Force a single copy of these singletons so MantineProvider context is
// shared between the backoffice app and @edr/ui-common (which ships its
// own node_modules copy). Without this, two separate @mantine/core
// instances are bundled and the context lookup fails at runtime.
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
server: {
port: 5183,

View File

@@ -0,0 +1 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';

View File

@@ -1,4 +1,5 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
import { client } from "../utils/api";
const P = URL_CONSTANTS.PAYMENTS;
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
method: PaymentMethod;
platform?: PaymentPlatform;
}): string {
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
const base = API_BASE_URL.replace(/\/$/, "");
const params = new URLSearchParams({
bookingId: payload.bookingId,
method: payload.method,

View File

@@ -1,9 +1,10 @@
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
baseURL: API_BASE_URL,
});
function getCookie(name: string): string | undefined {

View File

@@ -1,21 +1,38 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
// alone does not merge those into one module in production builds.
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
"@mantine/core": mantineCore,
"@mantine/hooks": mantineHooks,
},
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
optimizeDeps: {
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
},
server: {
port: 5173,
host: "0.0.0.0",
},
test: {
environment: "node",
},
});

View File

@@ -498,6 +498,7 @@ model Booking {
bookingRef String @unique
passengerId String
scheduleId String
bookingType String @default("ONE_WAY")
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
@@ -505,7 +506,11 @@ model Booking {
childCount Int @default(0)
displayCurrency Currency?
displayTotalMinor Int?
bookingType String @default("ONE_WAY")
returnScheduleId String?
returnOriginStationId String?
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
contactEmail String?
contactPhone String?
userAgent String?
@@ -526,6 +531,7 @@ model Booking {
baggage BaggageBooking[]
@@index([passengerId, status])
@@index([bookingType])
@@schema("passenger")
}

View File

@@ -144,9 +144,20 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create booking (requires login)',
description: `Creates a booking for logged-in users with saved passenger profiles.
Use POST /bookings/guest for guest checkout without login.`
summary: 'Create booking (one-way or round-trip)',
description: `Creates a one-way or round-trip booking for logged-in users.
ONE_WAY booking:
- scheduleId, holdId, originStationId, destinationStationId
- passengers: array of PassengerInputDto with seatId
- Single PNR, single payment
ROUND_TRIP booking:
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
- Combined PNR, single payment for both legs
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
})
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })

View File

@@ -15,53 +15,130 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
@ApiProperty({
description: 'Outbound journey seat ID',
example: 'seat-uuid-outbound'
})
@IsString() outboundSeatId: string;
@ApiProperty({
description: 'Return journey seat ID',
example: 'seat-uuid-return'
})
@IsString() returnSeatId: string;
@ApiProperty({
example: 'Abebe Kebede',
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
})
@IsString() passengerName: string;
@ApiProperty({
example: '1990-05-15',
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
})
@IsDateString() dateOfBirth: string;
@ApiProperty({
example: 'NATIONAL_ID',
enum: IdDocumentType,
description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others'
})
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({
example: 'ET123456789',
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
})
@IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({
example: 'P1234567',
description: 'Passport number for non-Ethiopian passengers (no verification)'
})
@IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({
example: 'Djibouti',
description: 'Passport issuing country for non-Ethiopians'
})
@IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)'
})
@IsOptional() @IsString() nationality?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() scheduleId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' })
@IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CreateRoundTripBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
@ApiProperty({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP'],
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
default: 'ONE_WAY'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
@ApiProperty({
type: [PassengerInputDto],
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
@ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' })
@IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
// Round-trip specific fields
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
})
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
})
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
})
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
})
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
})
@IsOptional() @IsString() returnSeatClassId?: string;
}
export class ModifyBookingDto {

View File

@@ -246,15 +246,19 @@ export class BookingsService {
}
async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') {
return this.createRoundTripBooking(dto);
}
return this.createOneWayBooking(dto);
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
});
if (!schedule) throw new NotFoundException('Schedule not found');
@@ -262,18 +266,182 @@ export class BookingsService {
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const seatIds = dto.passengers.map((p) => p.seatId);
const passengersData = [];
let adultCount = 0, childCount = 0;
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = fareCalculation.totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
}
for (const passenger of dto.passengers) {
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: fareCalculation.totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
displayCurrency
}))
}
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
this.eventEmitter.emit('booking.created', { booking });
return { ...booking, fareBreakdown: fareCalculation };
}
private async createRoundTripBooking(dto: CreateBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('Return trip details required for round-trip booking');
}
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
})
]);
if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found');
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) {
throw new NotFoundException('Origin or destination stops not found');
}
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const [outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
displayCurrency
}))
}
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
const returnSeatIds = passengersData.map(p => p.returnSeatId);
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds)
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
outboundFare: outboundFare.baseFareMinor,
returnFare: returnFare.baseFareMinor,
combinedBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor
}
};
}
private async processPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
@@ -292,68 +460,93 @@ export class BookingsService {
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
return processedPassengers;
}
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
private async processRoundTripPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = nationality || 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
return processedPassengers;
}
private countPassengers(passengersData: any[]) {
let adultCount = 0, childCount = 0;
for (const passenger of passengersData) {
if (passenger.category === PassengerCategory.ADULT) adultCount++;
else childCount++;
}
return { adultCount, childCount };
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
originStop: any,
destStop: any,
nationality?: string,
adultCount = 1,
childCount = 0,
promoCode?: string,
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
displayCurrency,
})),
},
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await this.seatsService.confirmSeats(seatIds);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
baseFareMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor
};
}

View File

@@ -134,9 +134,28 @@ export class FleetService {
}
async deleteCoachType(id: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
const coachType = await this.prisma.coachType.findUnique({
where: { id },
include: {
coaches: true,
seatClasses: true,
},
});
if (!coachType) throw new NotFoundException('Coach type not found');
// Check for related records
if (coachType.coaches.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
);
}
if (coachType.seatClasses.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
);
}
return this.prisma.coachType.delete({ where: { id } });
}
@@ -183,9 +202,29 @@ export class FleetService {
}
async deleteClass(id: string) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
const seatClass = await this.prisma.seatClass.findUnique({
where: { id },
include: {
fareRules: true,
routeFareRules: true,
segmentFares: true,
},
});
if (!seatClass) throw new NotFoundException('Seat class not found');
// Check for related records
const relatedRecords = [
...seatClass.fareRules,
...seatClass.routeFareRules,
...seatClass.segmentFares,
];
if (relatedRecords.length > 0) {
throw new BadRequestException(
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
);
}
return this.prisma.seatClass.delete({ where: { id } });
}
@@ -220,8 +259,21 @@ export class FleetService {
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
const train = await this.prisma.train.findUnique({
where: { id },
include: {
schedules: true,
},
});
if (!train) throw new NotFoundException('Train not found');
// Check for active schedules
if (train.schedules.length > 0) {
throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
);
}
return this.prisma.train.delete({ where: { id } });
}
@@ -305,10 +357,53 @@ export class FleetService {
}
async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
assignments: true,
seats: {
include: {
bookingSeats: true,
blocks: true,
ticketSeats: true,
},
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Delete related seats first
// Check for active assignments
if (coach.assignments.length > 0) {
throw new BadRequestException(
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
);
}
// Check for booked seats
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
);
}
// Check for blocked seats
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
);
}
// Check for tickets
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
if (seatsWithTickets.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
);
}
// Delete related seats first (now safe to do)
await this.prisma.seat.deleteMany({ where: { coachId: id } });
return this.prisma.coach.delete({ where: { id } });

View File

@@ -545,8 +545,13 @@ export class SchedulesService {
});
}
if (dto.coaches && dto.coaches.length > 0) {
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);
} else {
// Remove all coach assignments when empty array is sent
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
}
}
return this.getSchedule(id);

View File

@@ -204,14 +204,11 @@ export default function SchedulesPage() {
departureAt: editForm.departureAt,
arrivalAt: editForm.arrivalAt,
status: editForm.status,
};
if (editForm.coachIds.length > 0) {
payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
}));
}
})),
};
await updateScheduleMutation.mutateAsync({
id: editingSchedule.id,
@@ -327,14 +324,20 @@ export default function SchedulesPage() {
),
},
{
key: 'originStation.name',
label: 'From',
render: (schedule: Schedule) => <span>{schedule.originStation?.name}</span>,
},
{
key: 'destinationStation.name',
label: 'To',
render: (schedule: Schedule) => <span>{schedule.destinationStation?.name}</span>,
key: 'route',
label: 'Route',
sortable: true,
render: (schedule: Schedule) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{schedule.originStation?.name || 'Unknown'}
</span>
<span className="text-muted-foreground"></span>
<span className="text-sm font-medium">
{schedule.destinationStation?.name || 'Unknown'}
</span>
</div>
),
},
{
key: 'departureAt',

View File

@@ -420,7 +420,12 @@ export default function SeatsPage() {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
})
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
.sort((a: any, b: any) => {
// Try multiple sequence field possibilities
const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999;
const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999;
return seqA - seqB;
});
return (
<div className="space-y-6">
@@ -460,9 +465,31 @@ export default function SeatsPage() {
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : coachesWithSeats.length === 0 ? (
<div className="space-y-6">
<div className="card">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
<div className="card text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card h-fit sticky top-6 space-y-6">
@@ -526,7 +553,7 @@ export default function SeatsPage() {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1;
const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1;
return (
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">

View File

@@ -75,7 +75,6 @@ export default function StationsPage() {
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
timezone: formData.get('timezone') as string,
distance: parseFloat(formData.get('distance') as string) || 0,
sequence,
isOperational: formData.get('isOperational') === 'true',
};
@@ -136,13 +135,7 @@ export default function StationsPage() {
</div>
),
},
{
key: 'distance',
label: 'Distance (km)',
render: (station: any) => (
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
),
},
{
key: 'isOperational',
label: 'Status',
@@ -346,18 +339,6 @@ export default function StationsPage() {
))}
</select>
</div>
<div>
<label className="label">Distance from Previous (km)</label>
<input
type="number"
name="distance"
className="input"
defaultValue={editingStation?.distance || 0}
min="0"
step="0.1"
placeholder="e.g., 150.5"
/>
</div>
<div>
<label className="label">Sequence Number *</label>
<input

View File

@@ -14,38 +14,40 @@ export default function ResultsPage() {
const searchParams = useSearchParams();
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
const [outboundSelected, setOutboundSelected] = useState(false);
const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
// Prefer URL params; fall back to persisted store values
const searchData = {
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
date: searchParams.get('date') || searchCriteria?.departureDate || '',
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
};
// Sync URL params back into store whenever they are present in the URL
useEffect(() => {
if (searchParams.get('origin')) {
setSearchCriteria({
tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP',
originStationId: searchParams.get('origin')!,
destinationStationId: searchParams.get('destination')!,
departureDate: searchParams.get('date')!,
returnDate: searchParams.get('returnDate') || undefined,
adultCount: parseInt(searchParams.get('adults') || '1'),
childCount: parseInt(searchParams.get('children') || '0'),
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
promoCode: searchParams.get('promoCode') || '',
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [searchParams, setSearchCriteria]);
useEffect(() => {
if (searchData.promoCode) {
@@ -60,45 +62,91 @@ export default function ResultsPage() {
});
}
})
.catch((err) => {
console.error('Promo validation failed:', err);
.catch(() => {
// Promo validation failed - silently ignore
});
}
}, [searchData.promoCode]);
const buildSearchUrl = () => {
const params = new URLSearchParams({
tripType: searchData.journeyType,
origin: searchData.originStationId,
destination: searchData.destinationStationId,
date: searchData.date,
adults: searchData.adultCount.toString(),
children: searchData.childCount.toString(),
nationality: searchData.nationality,
...(searchData.returnDate && { returnDate: searchData.returnDate }),
...(searchData.promoCode && { promoCode: searchData.promoCode }),
});
return `/booking/search?${params}`;
};
const { data: results, isLoading, error } = useQuery<Schedule[]>({
const { data: results, isLoading, error } = useQuery<any>({
queryKey: ['search', searchData],
queryFn: async (): Promise<Schedule[]> => {
console.log('Searching with criteria:', searchData);
const response = await apiClient.post('/search', searchData) as Schedule[];
console.log('Search results:', response);
console.log('Number of results:', response?.length || 0);
if (response?.length > 0) {
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
queryFn: async (): Promise<any> => {
const payload: any = {
originStationId: searchData.originStationId,
destinationStationId: searchData.destinationStationId,
date: searchData.date,
adultCount: searchData.adultCount,
childCount: searchData.childCount,
nationality: searchData.nationality,
journeyType: searchData.journeyType,
};
if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) {
payload.returnDate = searchData.returnDate;
}
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
const response = await apiClient.post('/search', payload) as any;
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
});
const handleSelectClass = (scheduleId: string, seatClass: string) => {
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
// Handle both response formats:
// 1. One-way: response is array of schedules
// 2. Round-trip: response has journeyType, outbound, inbound properties
let outboundSchedules: Schedule[] = [];
let inboundSchedules: Schedule[] = [];
if (results) {
if (isRoundTrip && results.journeyType === 'ROUND_TRIP') {
// Round trip response format
outboundSchedules = results.outbound || [];
inboundSchedules = results.inbound || [];
} else if (Array.isArray(results)) {
// One-way response format (array of schedules)
outboundSchedules = results;
} else if (results.data && Array.isArray(results.data)) {
// Fallback: wrapped in data property
outboundSchedules = results.data;
}
}
// For one-way, check if outbound has results
// For round-trip, check if BOTH outbound and inbound have results
const hasResults = isRoundTrip
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
if (isOutbound && isRoundTrip) {
setOutboundSelected(true);
}
};
const handleSelect = (schedule: Schedule) => {
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
@@ -120,7 +168,7 @@ export default function ResultsPage() {
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
setSelectedSchedule({
const scheduleData = {
id: scheduleId,
trainNumber: schedule.trainNumber,
origin: schedule.origin?.name || 'Origin',
@@ -132,205 +180,21 @@ export default function ResultsPage() {
baseFareChild: selectedClassFare.baseFareMinor,
selectedSeatClass: selectedClass,
selectedSeatClassName: selectedClass,
});
};
// For round trip, store outbound and wait for inbound selection
if (isRoundTrip && isOutbound) {
setOutboundSelected(true);
setClassModal(null);
return;
}
// For round trip inbound or one-way, proceed to next step
setSelectedSchedule(scheduleData);
router.push('/booking/auth-check');
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 font-medium">Searching for trains...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="card max-w-md text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Search Error</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">Unable to load results. Please try again.</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Back to Search
</button>
</div>
</div>
);
}
if (!results || results.length === 0) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains matching your search criteria. <br /> Try adjusting your dates or route.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Modify search
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Class selection modal */}
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedClass = selectedClasses[scheduleId];
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
{/* Drawer */}
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
<Train className="w-3 h-3" />
{classModal.trainNumber} · {classModal.origin?.name} {classModal.destination?.name}
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Class grid */}
<div className="flex-1 overflow-y-auto p-4">
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{classModal.faresByClass.map((fareClass: any) => {
const isSelected = selectedClass === fareClass.seatClassName;
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
const isAvailable = availableSeats > 0;
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
return (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
disabled={!isAvailable}
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
}`}
>
{isSelected && (
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3.5 h-3.5 text-white" />
</div>
)}
<p className="font-semibold text-gray-900 dark:text-white pr-8">
{fareClass.seatClassName.replace(/_/g, ' ')}
</p>
<p className="text-xl font-bold text-primary dark:text-white mt-2">
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
</p>
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
<p className={`text-xs mt-2 font-medium ${
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
}`}>
{isAvailable
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
: 'Sold out'}
</p>
</button>
);
})}
</div>
) : (
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
disabled={!selectedClass}
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
>
<span>Continue</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedClass && (
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
)}
</div>
</div>
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
</>
);
})()}
{/* Promo Notification */}
{promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
<span className="font-mono font-bold">{promoData.code}</span> {promoData.message}
</p>
</div>
</div>
)}
<div className="mb-8">
<button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
<ChevronLeft className="w-4 h-4" />
Modify search
</button>
<h1 className="section-title">Available schedules</h1>
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{searchData.adultCount} adult(s), {searchData.childCount} child(ren)</span>
</div>
{searchData.promoCode && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-3 py-1 rounded-full text-sm">
<Gift className="w-4 h-4" />
<span>{searchData.promoCode}</span>
</div>
)}
</div>
</div>
<div className="space-y-4">
{results.map((schedule) => {
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
@@ -346,7 +210,6 @@ export default function ResultsPage() {
return (
<div key={scheduleId} className="card">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
{/* Train info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
@@ -399,7 +262,6 @@ export default function ResultsPage() {
</div>
</div>
{/* Fare + action */}
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
<div className="text-center lg:text-right">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
@@ -413,7 +275,7 @@ export default function ResultsPage() {
</p>
)}
<button
onClick={() => setClassModal(schedule)}
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{selectedClass ? 'Change class' : 'Select class'}
@@ -423,8 +285,232 @@ export default function ResultsPage() {
</div>
</div>
);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 font-medium">Searching for trains...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="card max-w-md text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Search Error</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">Unable to load results. Please try again.</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Back to Search
</button>
</div>
</div>
);
}
if (!hasResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains matching your search criteria. <br /> Try adjusting your dates or route.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Modify search
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedClass = selectedClasses[scheduleId];
const isOutbound = (classModal as any).isOutbound;
return (
<>
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
<Train className="w-3 h-3" />
{classModal.trainNumber} · {classModal.origin?.name} {classModal.destination?.name}
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{classModal.faresByClass.map((fareClass: any) => {
const isSelected = selectedClass === fareClass.seatClassName;
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
const isAvailable = availableSeats > 0;
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
return (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
disabled={!isAvailable}
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
}`}
>
{isSelected && (
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3.5 h-3.5 text-white" />
</div>
)}
<p className="font-semibold text-gray-900 dark:text-white pr-8">
{fareClass.seatClassName.replace(/_/g, ' ')}
</p>
<p className="text-xl font-bold text-primary dark:text-white mt-2">
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
</p>
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
<p className={`text-xs mt-2 font-medium ${
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
}`}>
{isAvailable
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
: 'Sold out'}
</p>
</button>
);
})}
</div>
) : (
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
disabled={!selectedClass}
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
>
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedClass && (
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
)}
</div>
</div>
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
</>
);
})()}
{promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
<span className="font-mono font-bold">{promoData.code}</span> {promoData.message}
</p>
</div>
</div>
)}
<div className="mb-8">
<button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
<ChevronLeft className="w-4 h-4" />
Modify search
</button>
<h1 className="section-title">Available schedules</h1>
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{searchData.adultCount} adult(s), {searchData.childCount} child(ren)</span>
</div>
{searchData.promoCode && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-3 py-1 rounded-full text-sm">
<Gift className="w-4 h-4" />
<span>{searchData.promoCode}</span>
</div>
)}
</div>
</div>
<div className="space-y-8">
{outboundSchedules.length > 0 && (
<div>
{isRoundTrip && (
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary" />
Outbound Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
</p>
</div>
)}
<div className="space-y-4">
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
</div>
</div>
)}
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
<div>
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
Return Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
</p>
</div>
<div className="space-y-4">
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))}
</div>
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -17,9 +17,11 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
originStationId: z.string().min(1, 'Please select origin station'),
destinationStationId: z.string().min(1, 'Please select destination station'),
departureDate: z.string().min(1, 'Please select departure date'),
returnDate: z.string().optional(),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
@@ -27,6 +29,22 @@ const searchSchema = z.object({
}).refine((d) => d.originStationId !== d.destinationStationId, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
}).refine((d) => {
if (d.tripType === 'ROUND_TRIP' && !d.returnDate) {
return false;
}
return true;
}, {
message: 'Please select return date',
path: ['returnDate'],
}).refine((d) => {
if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) {
return d.returnDate >= d.departureDate;
}
return true;
}, {
message: 'Return date must be after departure date',
path: ['returnDate'],
});
type SearchForm = z.infer<typeof searchSchema>;
@@ -423,6 +441,7 @@ export default function SearchPage() {
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema as any),
defaultValues: {
tripType: 'ONE_WAY',
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',
@@ -468,6 +487,8 @@ export default function SearchPage() {
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const departureDate = watch('departureDate');
const returnDate = watch('returnDate');
const tripType = watch('tripType');
const totalPassengers = (adultCount || 1) + (childCount || 0);
const saveRecent = useCallback((id: string) => {
@@ -510,12 +531,14 @@ export default function SearchPage() {
if (data.originStationId) saveRecent(data.originStationId);
if (data.destinationStationId) saveRecent(data.destinationStationId);
const params = new URLSearchParams({
tripType: data.tripType,
origin: data.originStationId,
destination: data.destinationStationId,
date: data.departureDate,
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }),
...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
@@ -615,6 +638,34 @@ export default function SearchPage() {
<div className="p-4 md:p-5">
{/* Trip Type Tabs */}
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue('tripType', 'ONE_WAY')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === 'ONE_WAY'
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue('tripType', 'ROUND_TRIP')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === 'ROUND_TRIP'
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Round Trip
</button>
</div>
</div>
{/* Mobile: stacked */}
<div className="flex flex-col gap-3 md:hidden">
<div className="space-y-1.5">
@@ -660,6 +711,20 @@ export default function SearchPage() {
/>
</div>
</div>
{tripType === 'ROUND_TRIP' && (
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return Date</label>
<div className="relative z-20">
<ModernDatePicker
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
placeholder="Select return date"
/>
</div>
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
</div>
)}
{/* Pax + Nationality combined trigger */}
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
@@ -676,8 +741,11 @@ export default function SearchPage() {
</button>
</div>
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
<div className="hidden md:flex items-end gap-2">
{/* Desktop: dynamic layout based on trip type */}
<div className={`hidden md:block`}>
{tripType === 'ONE_WAY' ? (
// ONE WAY: Single row layout
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
@@ -706,14 +774,14 @@ export default function SearchPage() {
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Select date"
minDate={new Date()} placeholder="Departure"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Pax + Nationality combined — opens shared modal */}
{/* Pax + Nationality */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
@@ -732,8 +800,132 @@ export default function SearchPage() {
Search
</button>
</div>
) : (
// ROUND TRIP: Two row layout
<div className="space-y-3">
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
{/* Swap */}
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Departure Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Return Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
<div className="relative z-20">
<ModernDatePicker
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
placeholder="Select date"
/>
</div>
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
</div>
</div>
{/* Promo */}
{/* Row 2: Promo, Passengers, Search */}
<div className="flex items-end gap-2">
{/* Promo Code */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{!promoVisible ? 'Promo Code (Optional)' : 'Promo Code'}
</label>
{!promoVisible ? (
<button type="button" onClick={() => setPromoVisible(true)}
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left">
<Gift className="w-4 h-4 text-primary" />
<span className="text-sm text-gray-400">Click to add promo code</span>
</button>
) : (
<div className="space-y-1">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input type="text" value={promoCode}
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
placeholder="Enter promo code"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
autoFocus />
</div>
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors">
{promoLoading ? '...' : 'Apply'}
</button>
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{promoValidation && (
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`}>
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
{promoValidation.message}
</div>
)}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
{/* Pax + Nationality */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all">
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
</div>
{/* Search Button */}
<div className="flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">Search</label>
<button type="submit" disabled={isLoading}
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
</div>
</div>
)}
</div>
{/* Promo - Only visible in ONE WAY mode on desktop */}
{tripType === 'ONE_WAY' && (
<div className="mt-3">
{!promoVisible ? (
<button type="button" onClick={() => setPromoVisible(true)}
@@ -771,6 +963,7 @@ export default function SearchPage() {
</div>
)}
</div>
)}
</div>
</div>

View File

@@ -50,6 +50,7 @@ export default function SeatsPage() {
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
const [modalState, setModalState] = useState({
isOpen: false,
title: '',
@@ -57,6 +58,8 @@ export default function SeatsPage() {
type: 'info' as 'warning' | 'error' | 'success' | 'info',
});
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const { data: seatMapData, isLoading, error } = useQuery({
queryKey: ['seatmap', selectedSchedule?.id],
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
@@ -70,10 +73,15 @@ export default function SeatsPage() {
seatId: seatIds[i],
}));
// For round trip inbound, swap origin and destination
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId;
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
return apiClient.post(`/seats/hold`, {
scheduleId: selectedSchedule?.id,
originStationId: searchCriteria?.originStationId,
destinationStationId: searchCriteria?.destinationStationId,
originStationId: originId,
destinationStationId: destinationId,
passengers: passengersForHold,
});
},
@@ -157,7 +165,10 @@ export default function SeatsPage() {
}, [passengers.length]);
const handleContinue = async () => {
if (isRoundTrip && currentJourneyType === 'outbound') {
// Save outbound seats and show inbound
if (selectedSeats.length > 0) {
try {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
@@ -168,6 +179,44 @@ export default function SeatsPage() {
};
});
setPassengers(updatedPassengers);
} catch (error: any) {
setModalState({
isOpen: true,
title: 'Seat Hold Failed',
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
type: 'error',
});
return;
}
}
setCurrentJourneyType('inbound');
setSelectedSeats([]);
setSelectedCoach(null);
return;
}
// Final continue (one-way or round-trip inbound)
if (selectedSeats.length > 0) {
try {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return {
...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers);
} catch (error: any) {
setModalState({
isOpen: true,
title: 'Seat Hold Failed',
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
type: 'error',
});
return;
}
}
router.push('/booking/review');
};
@@ -449,7 +498,14 @@ export default function SeatsPage() {
disabled={selectedSeats.length === 0 || holdMutation.isPending}
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
>
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
{holdMutation.isPending
? 'Holding seats...'
: isRoundTrip && currentJourneyType === 'outbound'
? 'Continue to Return Seats'
: allSelected
? 'Continue'
: 'Continue with partial selection'
}
</button>
<button
onClick={handleAutoAssign}
@@ -496,7 +552,12 @@ export default function SeatsPage() {
<ChevronLeft className="w-4 h-4" />
Back
</button>
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
<h1 className="text-base font-bold text-gray-900 dark:text-white">
{isRoundTrip
? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats')
: 'Select Seats'
}
</h1>
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
{selectedSeats.length}/{passengers.length}
</div>

View File

@@ -13,9 +13,11 @@ import { useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
originStationId: z.string().min(1),
destinationStationId: z.string().min(1),
departureDate: z.string().min(1),
returnDate: z.string().optional(),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
@@ -44,6 +46,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema as any),
defaultValues: {
tripType: 'ONE_WAY',
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',

View File

@@ -5,6 +5,8 @@ export interface SearchCriteria {
originStationId: string;
destinationStationId: string;
departureDate: string;
returnDate?: string;
tripType: 'ONE_WAY' | 'ROUND_TRIP';
adultCount: number;
childCount: number;
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';

View File

@@ -57,7 +57,7 @@ services:
TURBO_FILTER: "@edr/freight-portal"
APP_PATH: apps/edr-freight-web/portal
# Browser-reachable URL; override for production deployments
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
secrets:
- npmrc
ports:
@@ -70,7 +70,7 @@ services:
args:
TURBO_FILTER: "@edr/freight-backoffice"
APP_PATH: apps/edr-freight-web/backoffice
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
secrets:
- npmrc
ports:

View File

@@ -2,7 +2,7 @@
ARG TURBO_FILTER=@edr/freight-portal
ARG APP_PATH=apps/edr-freight-web/portal
ARG VITE_API_URL=http://localhost:3001/api
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
FROM node:24.15.0-alpine AS base

View File

@@ -137,7 +137,7 @@ export interface ConfirmPaymentRequest {
}
/** Response of `POST /payments/initiate` and shape of intent lookups. */
export interface PaymentIntentSnapshot {
export type PaymentIntentSnapshot ={
intentId: string;
service: PaymentService;
referenceType: PaymentReferenceType;

View File

@@ -2,5 +2,5 @@ export * from "./common/index";
export * from "./freight/index";
export * as Freight from "./freight/index";
export * as Passenger from "./passenger/index";
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent } from "./common/payments";
export { PaymentIntentSnapshot, InitiatePaymentRequest, PaymentReferenceType, PaymentService } from "./common/payments";
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
export { PaymentReferenceType, PaymentService } from "./common/payments";

2
pnpm-lock.yaml generated
View File

@@ -436,7 +436,7 @@ importers:
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices':
specifier: ^11.1.24
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)