mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' into passenger/feat/iam-integration
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
**/.git
|
||||
**/.github
|
||||
**/.vscode
|
||||
**/.git
|
||||
**/.idea
|
||||
**/.env
|
||||
.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/coverage
|
||||
**/*.tsbuildinfo
|
||||
**/*.log
|
||||
.DS_Store
|
||||
|
||||
96
.github/workflows/deploy.yaml
vendored
96
.github/workflows/deploy.yaml
vendored
@@ -1,96 +0,0 @@
|
||||
name: Automatic Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
environment:
|
||||
name: 🌍 Setup Environment
|
||||
runs-on: [self-hosted]
|
||||
outputs:
|
||||
target: ${{ steps.dev.outputs.target || steps.staging.outputs.target }}
|
||||
steps:
|
||||
- name: Verify NPM Token
|
||||
env:
|
||||
# We map the secret here to check its existence
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$NPM_TOKEN" ]; then
|
||||
echo "::error::The NPM_TOKEN secret is missing or empty. Please add it to your GitHub Secrets."
|
||||
exit 1
|
||||
fi
|
||||
echo "NPM_TOKEN is present, proceeding with build..."
|
||||
|
||||
- name: 🛠️ Set Development Environment
|
||||
id: dev
|
||||
if: ${{github.ref_name == 'dev'}}
|
||||
run: |
|
||||
echo "target=dev" >> $GITHUB_OUTPUT
|
||||
- name: 🚀 Set Staging Environment
|
||||
id: staging
|
||||
if: ${{github.ref_name == 'staging'}}
|
||||
run: |
|
||||
echo "target=staging" >> $GITHUB_OUTPUT
|
||||
|
||||
build-base-image:
|
||||
name: 🏗️ Build Base Image
|
||||
runs-on: [self-hosted, dev]
|
||||
needs: [environment]
|
||||
steps:
|
||||
- name: 🔍 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🐳 Build Docker Image
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Create the multi-line file
|
||||
cat <<EOF > .npmrc_temp
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
EOF
|
||||
|
||||
# Build using the file
|
||||
docker build --secret id=npmrc,src=.npmrc_temp -t edr-${{needs.environment.outputs.target}} .
|
||||
docker build --secret id=npmrc,src=.npmrc_temp --target passenger-migration -t edr-passenger-migration-${{needs.environment.outputs.target}} .
|
||||
|
||||
# Shred/Remove the sensitive file
|
||||
rm .npmrc_temp
|
||||
|
||||
deploy-service:
|
||||
name: ${{ matrix.display_name }}
|
||||
runs-on: [self-hosted, dev]
|
||||
needs: [build-base-image, environment]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: freight-api
|
||||
env_file: .env.freight-api
|
||||
display_name: 🚚 Deploy Freight API Service
|
||||
- service: passenger-api
|
||||
env_file: .env.passenger-api
|
||||
display_name: 🧑🦲 Deploy Passenger API Service
|
||||
|
||||
steps:
|
||||
- name: 🔍 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 📋 Copy ${{ matrix.service }} Environment
|
||||
run: cp ~/environment/edr/${{needs.environment.outputs.target}}/${{ matrix.env_file }} .env
|
||||
|
||||
- name: 🧪 Run Passenger API migrations
|
||||
if: ${{ matrix.service == 'passenger-api' }}
|
||||
run: |
|
||||
docker run --rm --env-file .env edr-passenger-migration-${{needs.environment.outputs.target}}
|
||||
|
||||
- name: 🚀 Start ${{ matrix.service }} Service
|
||||
run: docker compose --project-name="edr-${{needs.environment.outputs.target}}" up -d --force-recreate ${{ matrix.service }} --build
|
||||
92
.github/workflows/deploy.yml
vendored
Normal file
92
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Deploy Stacks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- staging
|
||||
paths:
|
||||
- "apps/edr-freight-api/**"
|
||||
- "apps/edr-freight-web/**"
|
||||
- "apps/edr-passenger-api/**"
|
||||
- "apps/edr-passenger-web/**"
|
||||
- "packages/**"
|
||||
- "infrastructure/docker/Dockerfile.web"
|
||||
- "infrastructure/nginx/**"
|
||||
- "docker-compose.yaml"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/deploy/**"
|
||||
- ".github/workflows/deploy.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy ${{ matrix.service }}
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- project: edr-freight
|
||||
build_env_file: freight-web.build.env
|
||||
service: freight-api
|
||||
# - project: edr-freight
|
||||
# build_env_file: freight-web.build.env
|
||||
# service: freight-portal
|
||||
# - project: edr-freight
|
||||
# build_env_file: freight-web.build.env
|
||||
# service: freight-backoffice
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-api
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-portal
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-backoffice
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
DEPLOY_USER: tria
|
||||
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
|
||||
DOCKER_BUILDKIT: "1"
|
||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync environment from server
|
||||
run: |
|
||||
chmod +x scripts/deploy/*.sh
|
||||
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
|
||||
|
||||
- name: Set compose project name
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")
|
||||
echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure npm auth for Docker builds
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: ./scripts/deploy/create-npmrc.sh
|
||||
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
||||
|
||||
- name: Deploy ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
|
||||
|
||||
- name: Remove npm credentials from workspace
|
||||
if: always()
|
||||
run: rm -f .npmrc .npmrc_temp
|
||||
11
.npmrc
Normal file
11
.npmrc
Normal file
@@ -0,0 +1,11 @@
|
||||
# Increase fetch timeouts for network resilience
|
||||
fetch-timeout=60000
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
|
||||
# GitHub Packages configuration for @tria-plc scope
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
|
||||
|
||||
# Default registry for other packages
|
||||
registry=https://registry.npmjs.org/
|
||||
187
DEPLOYMENT.md
Normal file
187
DEPLOYMENT.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Deployment Runbook
|
||||
|
||||
This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners.
|
||||
|
||||
## Overview
|
||||
|
||||
- Monorepo contains 6 deployable services:
|
||||
- `freight-api`
|
||||
- `freight-portal`
|
||||
- `freight-backoffice`
|
||||
- `passenger-api`
|
||||
- `passenger-portal`
|
||||
- `passenger-backoffice`
|
||||
- Deployments run through one workflow: `.github/workflows/deploy.yml`
|
||||
- Each service is built/deployed independently in parallel (matrix jobs).
|
||||
- Docker Compose project names are branch-aware to avoid environment collisions on the same host.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine with Compose plugin on the self-hosted runner.
|
||||
- GitHub self-hosted runner registered for this repository.
|
||||
- Repository secret configured:
|
||||
- `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build)
|
||||
- Server-side env files created for each branch/environment.
|
||||
|
||||
## Server Environment Files
|
||||
|
||||
`sync-env-from-server.sh` reads env files from:
|
||||
|
||||
`/home/<DEPLOY_USER>/environment/edr/<branch-slug>/<project>/`
|
||||
|
||||
Where:
|
||||
|
||||
- `<DEPLOY_USER>` defaults to `tria` (overridable by `DEPLOY_USER`)
|
||||
- `<branch-slug>` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`)
|
||||
- `<project>` is `edr-freight` or `edr-passenger`
|
||||
|
||||
### Required files per project
|
||||
|
||||
For `edr-freight`:
|
||||
|
||||
- `freight-api.env`
|
||||
- `freight-portal.env`
|
||||
- `freight-backoffice.env`
|
||||
- optional: `freight-web.build.env`
|
||||
|
||||
For `edr-passenger`:
|
||||
|
||||
- `passenger-api.env`
|
||||
- `passenger-portal.env`
|
||||
- `passenger-backoffice.env`
|
||||
- optional: `passenger-web.build.env`
|
||||
|
||||
### Required env key
|
||||
|
||||
Each service env file must contain:
|
||||
|
||||
- `PORT=<number>`
|
||||
|
||||
The sync script validates this and fails if missing.
|
||||
|
||||
### Build env files (optional)
|
||||
|
||||
Used for build-time variables (example: Vite API URLs), with `export` syntax:
|
||||
|
||||
```bash
|
||||
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
|
||||
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
|
||||
```
|
||||
|
||||
These are injected into `GITHUB_ENV` during workflow execution.
|
||||
|
||||
## Docker Compose Port Mapping
|
||||
|
||||
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
|
||||
|
||||
- `FREIGHT_API_PORT`
|
||||
- `PASSENGER_API_PORT`
|
||||
- `FREIGHT_PORTAL_PORT`
|
||||
- `FREIGHT_BACKOFFICE_PORT`
|
||||
- `PASSENGER_PORTAL_PORT`
|
||||
- `PASSENGER_BACKOFFICE_PORT`
|
||||
|
||||
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
|
||||
|
||||
## GitHub Actions Deployment Flow
|
||||
|
||||
Workflow file: `.github/workflows/deploy.yml`
|
||||
|
||||
### 1) `prepare` job
|
||||
|
||||
- Checks out repository once.
|
||||
- Creates workspace artifact (`workspace.tgz`) and uploads it.
|
||||
|
||||
### 2) `deploy` matrix job (parallel)
|
||||
|
||||
For each service:
|
||||
|
||||
- Downloads and extracts workspace artifact.
|
||||
- Syncs that service env file from server path.
|
||||
- Computes branch slug and sets:
|
||||
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
||||
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||
- Runs:
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
||||
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||
|
||||
## Branch/Environment Isolation
|
||||
|
||||
Compose project name is generated as:
|
||||
|
||||
`<project>-<branch-slug>`
|
||||
|
||||
Examples:
|
||||
|
||||
- `edr-freight-main`
|
||||
- `edr-freight-staging`
|
||||
- `edr-passenger-dev`
|
||||
|
||||
This prevents container/network/volume name collisions between branches.
|
||||
|
||||
## Local Manual Deployment (Optional)
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker compose build <service>
|
||||
docker compose up -d <service>
|
||||
```
|
||||
|
||||
If private packages are required locally, create `.npmrc`:
|
||||
|
||||
```bash
|
||||
cat <<EOF > .npmrc
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
|
||||
always-auth=true
|
||||
EOF
|
||||
```
|
||||
|
||||
## Passenger API Startup Behavior
|
||||
|
||||
Passenger container entrypoint runs on startup:
|
||||
|
||||
1. `npm run prisma:generate`
|
||||
2. `npm run prisma:migrate` (deploy mode)
|
||||
3. `npm run prisma:seed`
|
||||
4. starts API process
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing env file
|
||||
|
||||
Error:
|
||||
|
||||
- `Missing env file: ...`
|
||||
|
||||
Fix:
|
||||
|
||||
- Create the required file in the server env directory for that project/branch slug.
|
||||
|
||||
### Missing PORT in env file
|
||||
|
||||
Error:
|
||||
|
||||
- `Missing required PORT in env file: ...`
|
||||
|
||||
Fix:
|
||||
|
||||
- Add `PORT=<number>` to that service env file.
|
||||
|
||||
### Private package install fails
|
||||
|
||||
Check:
|
||||
|
||||
- `NPM_TOKEN` exists in repo secrets.
|
||||
- Workflow created `.npmrc` successfully.
|
||||
|
||||
### Prisma seed/migrate failures (passenger)
|
||||
|
||||
Check:
|
||||
|
||||
- `DATABASE_URL` in `passenger-api.env`
|
||||
- DB reachability from runner host/container network
|
||||
- migration history consistency
|
||||
|
||||
84
Dockerfile
84
Dockerfile
@@ -1,84 +0,0 @@
|
||||
FROM node:24.15.0 AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest-11 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
|
||||
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
||||
COPY apps/edr-passenger-api/package.json ./apps/edr-passenger-api/
|
||||
|
||||
COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/
|
||||
COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/
|
||||
|
||||
COPY apps/edr-passenger-web/backoffice/package.json ./apps/edr-passenger-web/backoffice/
|
||||
COPY apps/edr-passenger-web/portal/package.json ./apps/edr-passenger-web/portal/
|
||||
|
||||
|
||||
COPY packages/api-common/package.json packages/api-common/
|
||||
|
||||
COPY packages/config/eslint-config/package.json packages/config/eslint-config/
|
||||
COPY packages/config/prettier-config/package.json packages/config/prettier-config/
|
||||
COPY packages/config/tsconfig/package.json packages/config/tsconfig/
|
||||
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/ui-common/package.json packages/ui-common/
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store\
|
||||
--mount=type=secret,id=npmrc,target=./.npmrc \
|
||||
pnpm install --frozen-lockfile
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build --filter=\!"@edr/passenger-portal"
|
||||
|
||||
FROM base AS freight-api
|
||||
# RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app/apps/edr-freight-api
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./../../node_modules
|
||||
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
||||
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
||||
COPY --from=build /app/packages ./../../packages
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
|
||||
FROM base AS passenger-api
|
||||
RUN apt-get update -y && apt-get install -y openssl
|
||||
# RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app/apps/edr-passenger-api
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Use build-stage node_modules (not deps): `pnpm run build` runs `prisma generate`, which
|
||||
# writes the real @prisma/client (enums, types). deps never runs generate, so @IsEnum(ServiceClass)
|
||||
# and similar would see undefined at runtime if we copied deps only.
|
||||
COPY --from=build /app/node_modules ./../../node_modules
|
||||
COPY --from=build /app/apps/edr-passenger-api/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/edr-passenger-api/dist ./dist
|
||||
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
|
||||
COPY --from=build /app/packages ./../../packages
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
FROM build as passenger-migration
|
||||
WORKDIR /app/apps/edr-passenger-api
|
||||
CMD pnpm run prisma:migrate && pnpm run prisma:seed
|
||||
|
||||
|
||||
|
||||
FROM nginx:1.27-alpine AS freight-web-portal
|
||||
COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html
|
||||
EXPOSE 5173
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
FROM nginx:1.27-alpine AS freight-web-backoffice
|
||||
COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html
|
||||
EXPOSE 5173
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
190
README.md
190
README.md
@@ -170,7 +170,7 @@ pnpm --filter @edr/passenger-api run prisma:generate
|
||||
|
||||
#### Run Migrations
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate:dev
|
||||
```
|
||||
|
||||
#### Seed Database
|
||||
@@ -314,9 +314,34 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Register International Passenger
|
||||
#### 4. Universal Passenger Registration (NEW)
|
||||
```bash
|
||||
POST /passengers/register-international
|
||||
# Guest Ethiopian with Fayda verification
|
||||
POST /passengers/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"nationalId": "ET123456789",
|
||||
"phone": "+251911234567",
|
||||
"deviceId": "device-uuid-123"
|
||||
}
|
||||
|
||||
# Logged-in user with JWT token
|
||||
POST /passengers/register
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"nationalId": "ET123456789",
|
||||
"phone": "+251911234567"
|
||||
}
|
||||
|
||||
# International passenger (passport)
|
||||
POST /passengers/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -326,11 +351,42 @@ Content-Type: application/json
|
||||
"passportCountry": "Kenya",
|
||||
"nationality": "Kenyan",
|
||||
"phone": "+254712345678",
|
||||
"email": "john@example.com"
|
||||
"email": "john@example.com",
|
||||
"deviceId": "device-uuid-123"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Search Trips
|
||||
#### 5. Get User Profile (NEW)
|
||||
```bash
|
||||
GET /auth/profile
|
||||
Authorization: Bearer <jwt-token>
|
||||
|
||||
# Response includes user, passenger, loyalty, and wallet details
|
||||
{
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"phone": "+251911234567",
|
||||
"fullName": "John Doe",
|
||||
"role": "PASSENGER",
|
||||
"nationality": "Ethiopian",
|
||||
"faydaVerified": true,
|
||||
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
|
||||
"passenger": {
|
||||
"id": "uuid",
|
||||
"loyalty": {
|
||||
"tier": "SILVER",
|
||||
"pointsBalance": 1500,
|
||||
"lifetimePoints": 3000
|
||||
},
|
||||
"wallet": {
|
||||
"balanceMinor": 50000,
|
||||
"currency": "ETB"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Search Trips
|
||||
```bash
|
||||
POST /search
|
||||
Content-Type: application/json
|
||||
@@ -344,7 +400,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Get Fare Quote
|
||||
#### 7. Get Fare Quote
|
||||
```bash
|
||||
POST /search/fare-quote
|
||||
Content-Type: application/json
|
||||
@@ -373,7 +429,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. Guest Booking (No Login Required)
|
||||
#### 8. Guest Booking (No Login Required)
|
||||
```bash
|
||||
POST /bookings/guest
|
||||
Content-Type: application/json
|
||||
@@ -398,7 +454,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. Agent Booking (IAM Auth)
|
||||
#### 9. Agent Booking (IAM Auth)
|
||||
```bash
|
||||
POST /agents/bookings
|
||||
Authorization: Bearer <iam-token>
|
||||
@@ -523,59 +579,103 @@ pnpm --filter @edr/passenger-api run type-check # TypeScript check
|
||||
|
||||
# Database
|
||||
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate # Run migrations
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
|
||||
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Build Image
|
||||
All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**.
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
- Docker with BuildKit enabled
|
||||
- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images)
|
||||
- External Postgres for each API (compose does **not** include databases)
|
||||
- Copy `apps/edr-freight-api/.env.example` → `.env` and `apps/edr-passenger-api/.env.example` → `.env` with real connection strings
|
||||
|
||||
### Build and run (all apps)
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
docker build -f apps/edr-passenger-api/Dockerfile -t edr-passenger-api .
|
||||
DOCKER_BUILDKIT=1 pnpm docker:build
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
### Run Container
|
||||
Or without pnpm scripts:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name edr-api \
|
||||
-p 4000:4000 \
|
||||
--env-file apps/edr-passenger-api/.env \
|
||||
edr-passenger-api
|
||||
DOCKER_BUILDKIT=1 docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: edr
|
||||
POSTGRES_PASSWORD: edr_secret
|
||||
POSTGRES_DB: edr_passenger
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
| Service | URL (default) |
|
||||
|---------|----------------|
|
||||
| Freight API | http://localhost:3001 |
|
||||
| Passenger API | http://localhost:4000 |
|
||||
| Freight portal | http://localhost:5173 |
|
||||
| Freight backoffice | http://localhost:5183 |
|
||||
| Passenger portal | http://localhost:5174 |
|
||||
| Passenger backoffice | http://localhost:5184 |
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-passenger-api/Dockerfile
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://edr:edr_secret@postgres:5432/edr_passenger
|
||||
JWT_SECRET: your-secret-key
|
||||
PORT: 4000
|
||||
depends_on:
|
||||
- postgres
|
||||
### Build a single service
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```bash
|
||||
docker compose build freight-api
|
||||
docker compose build passenger-portal
|
||||
```
|
||||
|
||||
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
|
||||
|
||||
### `VITE_API_URL` (frontends)
|
||||
|
||||
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
|
||||
|
||||
```bash
|
||||
docker compose build freight-portal \
|
||||
--build-arg VITE_API_URL=https://freight-api.example.com/api
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
- **Freight API:** TypeORM migrations are not run on container startup — apply them separately before deploy.
|
||||
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
|
||||
|
||||
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
|
||||
|
||||
### GitHub Actions (self-hosted runner)
|
||||
|
||||
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
|
||||
|
||||
| Workflow | Services | Server env root |
|
||||
|----------|----------|-----------------|
|
||||
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
|
||||
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
|
||||
|
||||
**On the runner**, place env files before the first deploy (example for branch `main`):
|
||||
|
||||
```text
|
||||
/home/user/environmen/edr-freight/main/
|
||||
freight-api.env
|
||||
freight-portal.env # optional runtime env for Vite/nginx
|
||||
freight-backoffice.env
|
||||
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
|
||||
|
||||
/home/user/environmen/edr-passenger/main/
|
||||
passenger-api.env
|
||||
passenger-portal.env
|
||||
passenger-backoffice.env
|
||||
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
|
||||
```
|
||||
|
||||
Example `freight-web.build.env`:
|
||||
|
||||
```bash
|
||||
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
|
||||
```
|
||||
|
||||
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
# App
|
||||
NODE_ENV=development
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_NAME=edr_freight
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
|
||||
# JWT (provided by external auth package — placeholder only)
|
||||
JWT_SECRET=
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
DB_NAME=edr_freight
|
||||
|
||||
37
apps/edr-freight-api/Dockerfile
Normal file
37
apps/edr-freight-api/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/freight-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -132,4 +132,4 @@ FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
FAYDA_CLAIMS_LOCALES=en am
|
||||
FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
|
||||
51
apps/edr-passenger-api/Dockerfile
Normal file
51
apps/edr-passenger-api/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile .
|
||||
# On start: runs prisma migrate deploy + seed, then the API.
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/passenger-api" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm --filter "@edr/passenger-api" exec prisma generate
|
||||
RUN pnpm turbo build --filter="@edr/passenger-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
||||
RUN if [ -d node_modules/.prisma ]; then \
|
||||
mkdir -p /deploy/node_modules && \
|
||||
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
|
||||
fi
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer /deploy .
|
||||
COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh \
|
||||
&& chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
ENV CI=true
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
EXPOSE 4000
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
11
apps/edr-passenger-api/docker-entrypoint.sh
Normal file
11
apps/edr-passenger-api/docker-entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
# npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout)
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm run prisma:seed
|
||||
|
||||
exec "$@"
|
||||
@@ -54,6 +54,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"typeorm": "^0.3.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -62,7 +63,6 @@
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient, SeatKind } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -136,11 +136,11 @@ async function seedCoachesAndSeats(seatClasses: any[]) {
|
||||
col,
|
||||
label: `${row}${col}`,
|
||||
seatNumber: `${config.label}${row}${col}`,
|
||||
kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind,
|
||||
kind: row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD',
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.seat.createMany({ data: seats });
|
||||
await prisma.seat.createMany({ data: seats as any });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ async function seedSchedules(trains: any[], stations: any[], routes: any[]) {
|
||||
const existingScheduleIds = (await prisma.trainSchedule.findMany({
|
||||
where: { trainId: { in: [train301.id, train302.id, train303.id] } },
|
||||
select: { id: true },
|
||||
})).map((s) => s.id);
|
||||
})).map((s: { id: string }) => s.id);
|
||||
|
||||
if (existingScheduleIds.length > 0) {
|
||||
// Delete in correct order to avoid foreign key constraints
|
||||
|
||||
@@ -219,31 +219,32 @@ Payment providers send notifications to:
|
||||
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
|
||||
"JWT-auth",
|
||||
)
|
||||
.addTag("Agents", "Counter booking and management")
|
||||
.addTag("Auth", "Registration and login")
|
||||
.addTag("Booking", "Booking lifecycle")
|
||||
.addTag("Dashboard", "Home dashboard aggregate")
|
||||
.addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency")
|
||||
.addTag("Fleet", "Train services and coaches")
|
||||
.addTag("Fraud Detection", "Fraud detection and monitoring")
|
||||
.addTag("Live Tracking", "Real-time trip status and crowd signals")
|
||||
.addTag("Loyalty", "Points, tiers, and rewards")
|
||||
.addTag("Notifications", "Push and email notifications")
|
||||
.addTag("Passenger", "Profiles, traveler profiles, saved routes")
|
||||
.addTag("Payment", "Payment intents and refunds")
|
||||
.addTag("Payment Webhooks", "Endpoints for payment provider notifications")
|
||||
.addTag("Promotions", "Promo codes and campaigns")
|
||||
.addTag("Reports", "Sales and operational reports")
|
||||
.addTag("Routes", "Route information and management")
|
||||
.addTag("Schedule", "Trips and fare rules")
|
||||
.addTag("Search", "Trip search and fare quotes")
|
||||
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed")
|
||||
.addTag("Seats", "Seat maps and holds")
|
||||
.addTag("Segment-based Seats", "Seats assigned and released by trip segments")
|
||||
.addTag("Stations", "Station directory")
|
||||
.addTag("Support", "FAQ and chat support")
|
||||
.addTag("Tickets", "QR ticket generation and validation")
|
||||
.addTag("Wallet", "Wallet balance and ledger")
|
||||
.addTag("Agents", "Counter booking, shift management, and commission tracking")
|
||||
.addTag("Auth", "User registration, login, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
|
||||
.addTag("Dashboard", "Aggregated dashboard data for home screen")
|
||||
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
|
||||
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
|
||||
.addTag("Fleet", "Train services, coaches, and seat configurations")
|
||||
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
|
||||
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
|
||||
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
|
||||
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
|
||||
.addTag("Passengers", "Passenger registration, verification, and profiles")
|
||||
.addTag("Payment", "Payment processing, intents, and refunds")
|
||||
.addTag("Payment Webhooks", "Payment provider webhook handlers")
|
||||
.addTag("Promotions", "Promo codes, campaigns, and discount management")
|
||||
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
|
||||
.addTag("Routes", "Route templates with stops and fare rules")
|
||||
.addTag("Schedule", "Trip schedules, availability, and status updates")
|
||||
.addTag("Search", "Trip search, availability checks, and fare quotes")
|
||||
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
|
||||
.addTag("Seats", "Seat maps, holds, releases, and blocking")
|
||||
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
|
||||
.addTag("Stations", "Station directory and information")
|
||||
.addTag("Support", "FAQ management and live chat support")
|
||||
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
|
||||
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
// .addServer("https://api.edr-platform.com", "Production")
|
||||
.build();
|
||||
@@ -255,6 +256,8 @@ Payment providers send notifications to:
|
||||
persistAuthorization: true,
|
||||
docExpansion: "none",
|
||||
filter: true,
|
||||
tagsSorter: "alpha",
|
||||
operationsSorter: "alpha",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -78,4 +79,142 @@ export class AuthController {
|
||||
@ApiResponse({ status: 404, description: 'User not found' })
|
||||
@ApiBody({ type: ResetPasswordDto })
|
||||
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Logout current user',
|
||||
description: `Logout the authenticated user and invalidate their session.
|
||||
|
||||
### What happens:
|
||||
- Invalidates the current session token
|
||||
- Records logout in audit log
|
||||
- Frontend should clear stored token and redirect to home
|
||||
|
||||
### Authentication:
|
||||
- **Required**: JWT Bearer Token
|
||||
- Token will be invalidated after successful logout`
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Logout successful',
|
||||
schema: {
|
||||
example: {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||
logout(@Request() req: any) {
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
return this.service.logout(req.user.userId);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user profile',
|
||||
description: `**Returns complete user profile with all connected data**
|
||||
|
||||
---
|
||||
|
||||
### Response Includes
|
||||
|
||||
#### User Information
|
||||
- Basic details (id, email, phone, fullName, role)
|
||||
- Nationality and document information
|
||||
- Fayda verification status
|
||||
- Account timestamps (created, last login)
|
||||
|
||||
#### Passenger Data (if role=PASSENGER)
|
||||
- Passenger ID and preferences
|
||||
- **Loyalty Account**: Tier, points balance, lifetime points
|
||||
- **Wallet Account**: Balance (minor units), currency
|
||||
|
||||
#### User Preferences
|
||||
- Language, notification settings, etc.
|
||||
|
||||
---
|
||||
|
||||
### Use Cases
|
||||
|
||||
1. **App Initialization**: Fetch on app load to get user context
|
||||
|
||||
2. **Profile Pre-fill**: Use data to auto-fill booking forms
|
||||
|
||||
3. **Verification Check**: Check \`faydaVerified\` before registration
|
||||
|
||||
4. **Loyalty Display**: Show tier and points in UI
|
||||
|
||||
5. **Wallet Balance**: Display available balance
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
- **Required**: JWT Bearer Token
|
||||
- Token must be valid and not expired
|
||||
- Returns profile for authenticated user only`,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'user-uuid-123',
|
||||
email: 'kelemu@email.com',
|
||||
phone: '+251911234567',
|
||||
fullName: 'Kelemu Abebe',
|
||||
role: 'PASSENGER',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: 'ET',
|
||||
nationalId: null,
|
||||
passportNumber: null,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
|
||||
lastLoginAt: '2024-01-20T14:22:00.000Z',
|
||||
createdAt: '2023-12-01T08:00:00.000Z',
|
||||
passenger: {
|
||||
id: 'passenger-uuid-456',
|
||||
preferredLanguage: 'am',
|
||||
loyalty: {
|
||||
tier: 'SILVER',
|
||||
pointsBalance: 1500,
|
||||
lifetimePoints: 3000
|
||||
},
|
||||
wallet: {
|
||||
balanceMinor: 50000,
|
||||
currency: 'ETB'
|
||||
}
|
||||
},
|
||||
preferences: {
|
||||
emailNotifications: true,
|
||||
smsNotifications: true,
|
||||
language: 'am'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - Invalid or missing JWT token',
|
||||
schema: {
|
||||
example: {
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
})
|
||||
getProfile(@Request() req: any) {
|
||||
console.log('Profile request - User from JWT:', req.user);
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
return this.service.getProfile(req.user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,70 @@ export class AuthService {
|
||||
data: { userId, action, entityType, entityId, oldData, newData }
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('User ID not found in token');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
passenger: {
|
||||
include: {
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
},
|
||||
},
|
||||
preferences: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
nationality: user.nationality,
|
||||
nationalityCode: user.nationalityCode,
|
||||
nationalId: user.nationalId,
|
||||
passportNumber: user.passportNumber,
|
||||
faydaVerified: user.faydaVerified,
|
||||
faydaVerifiedAt: user.faydaVerifiedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
passenger: user.passenger ? {
|
||||
id: user.passenger.id,
|
||||
preferredLanguage: user.passenger.preferredLanguage,
|
||||
loyalty: user.passenger.loyalty ? {
|
||||
tier: user.passenger.loyalty.tier,
|
||||
pointsBalance: user.passenger.loyalty.pointsBalance,
|
||||
lifetimePoints: user.passenger.loyalty.lifetimePoints,
|
||||
} : null,
|
||||
wallet: user.passenger.wallet ? {
|
||||
balanceMinor: user.passenger.wallet.balanceMinor,
|
||||
currency: user.passenger.wallet.currency,
|
||||
} : null,
|
||||
} : null,
|
||||
preferences: user.preferences,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
// Invalidate all active sessions for this user
|
||||
await this.prisma.session.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
// Log the logout action
|
||||
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export class PassengerInputDto {
|
||||
@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: 'Kenya', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export class GuestPassengerDto {
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' })
|
||||
@IsOptional() @IsString() passportNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' })
|
||||
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' })
|
||||
@IsOptional() @IsString() passportCountry?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda), Djiboutian, Other' })
|
||||
|
||||
@@ -71,28 +71,42 @@ export class GuestBookingService {
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
// Verifayda verification ONLY for Ethiopian nationals with National ID
|
||||
const isEthiopian = !passenger.nationality || passenger.nationality === 'Ethiopian' ||
|
||||
(passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !passenger.passportCountry);
|
||||
// Determine if passenger is Ethiopian
|
||||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||||
passenger.nationality === 'ETHIOPIAN' ||
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && 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}`
|
||||
);
|
||||
// Ethiopian with National ID
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
// Attempt Fayda verification
|
||||
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;
|
||||
}
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
nationality = 'Ethiopian';
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
}
|
||||
// International passenger with Passport (non-Ethiopian)
|
||||
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
// Passport details are required for international passengers
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) {
|
||||
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
}
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !isEthiopian) {
|
||||
// Non-Ethiopian with national ID (e.g., Djiboutian national ID)
|
||||
}
|
||||
// Ethiopian with Passport (manual entry without Fayda)
|
||||
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
// Ethiopians can use passport instead of national ID
|
||||
nationality = 'Ethiopian';
|
||||
}
|
||||
// International with National ID (e.g., Djiboutian national ID)
|
||||
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
|
||||
@@ -175,11 +189,23 @@ export class GuestBookingService {
|
||||
createdAccount = true;
|
||||
} else {
|
||||
// Create anonymous guest passenger with minimal data
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Check if email exists and use a unique guest email if it does
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
if (firstPassenger.email) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) {
|
||||
// Email exists, use guest email instead for anonymous booking
|
||||
guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
}
|
||||
}
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email || `guest-${Date.now()}@edr-platform.com`,
|
||||
phone: firstPassenger.phone || `+251${Date.now()}`,
|
||||
email: guestEmail,
|
||||
phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
|
||||
@ApiTags('Passenger')
|
||||
@ApiTags('Passengers')
|
||||
@Controller('passengers')
|
||||
export class PassengersController {
|
||||
constructor(
|
||||
@@ -55,14 +56,51 @@ export class PassengersController {
|
||||
@Post('verify-fayda')
|
||||
@ApiOperation({
|
||||
summary: 'Verify Ethiopian national ID via Verifayda 2.0',
|
||||
description: `Verifies Ethiopian national ID and retrieves passenger data from government database.
|
||||
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs**
|
||||
|
||||
---
|
||||
|
||||
### Purpose
|
||||
Pre-verify national ID to auto-fill passenger registration form before submission.
|
||||
|
||||
---
|
||||
|
||||
### Flow
|
||||
|
||||
1. User enters national ID in form
|
||||
|
||||
2. Frontend calls \`POST /passengers/verify-fayda\`
|
||||
|
||||
3. API queries Verifayda 2.0 government database
|
||||
|
||||
4. Returns verified passenger data (name, DOB, gender)
|
||||
|
||||
5. Frontend auto-fills form with verified data
|
||||
|
||||
6. User submits form via \`POST /passengers/register\`
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
- Real-time verification via Verifayda 2.0 API
|
||||
- Retrieves verified passenger data (name, DOB, gender, nationality)
|
||||
- National IDs NOT stored (policy compliant)
|
||||
- Retrieves verified data: name, date of birth, gender, nationality
|
||||
- **National IDs NOT stored** (policy compliant)
|
||||
- Only for Ethiopian nationals with national ID
|
||||
- Non-Ethiopians should use passport (no verification required)
|
||||
- Returns passenger details for booking form auto-fill`,
|
||||
- Non-Ethiopians use passport (no verification)
|
||||
|
||||
---
|
||||
|
||||
### Important Notes
|
||||
- This is a **read-only** verification endpoint
|
||||
- Does NOT save passenger data to database
|
||||
- Use \`POST /passengers/register\` to actually register
|
||||
- Falls back to manual entry if Verifayda disabled or fails
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
- **Public endpoint** (no authentication required)
|
||||
- Can be called before login/registration`,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -84,21 +122,143 @@ export class PassengersController {
|
||||
return this.verifaydaService.verifyNationalId(dto.nationalId);
|
||||
}
|
||||
|
||||
@Post('register-international')
|
||||
@Post('register')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Register international passenger with passport details',
|
||||
description: `Saves international passenger profile for booking.
|
||||
summary: 'Universal passenger registration endpoint',
|
||||
description: `**Single endpoint for all passenger registration scenarios**
|
||||
|
||||
- For non-Ethiopian passengers (Djiboutian, Kenyan, etc.)
|
||||
- Collects passport information
|
||||
- No government verification required
|
||||
- Profile saved for future bookings
|
||||
- Can be used by logged-in users or guest users (via deviceId)`,
|
||||
---
|
||||
|
||||
### Automatic Detection
|
||||
The API automatically detects:
|
||||
- **Passenger Type**: Ethiopian (nationalId) vs International (passportNumber)
|
||||
- **Authentication**: Logged-in (JWT token) vs Guest (deviceId)
|
||||
- **Verification**: Auto-attempts Fayda for Ethiopian nationals
|
||||
|
||||
---
|
||||
|
||||
### Scenarios Handled
|
||||
|
||||
#### 1. Guest Ethiopian Passenger
|
||||
- Provide: \`nationalId\`, \`deviceId\`
|
||||
- Behavior: Attempts Fayda verification → Saves to SavedPassengerProfile
|
||||
- Response: \`verified: true/false\`, \`linked: false\`
|
||||
|
||||
#### 2. Guest International Passenger
|
||||
- Provide: \`passportNumber\`, \`passportCountry\`, \`deviceId\`
|
||||
- Behavior: No verification → Saves to SavedPassengerProfile
|
||||
- Response: \`verified: false\`, \`linked: false\`
|
||||
|
||||
#### 3. Logged-in Ethiopian Passenger
|
||||
- Provide: JWT token + \`nationalId\`
|
||||
- Behavior: Attempts Fayda verification → Updates user profile
|
||||
- Response: \`verified: true/false\`, \`linked: true\`
|
||||
|
||||
#### 4. Logged-in International Passenger
|
||||
- Provide: JWT token + \`passportNumber\`, \`passportCountry\`
|
||||
- Behavior: No verification → Updates user profile
|
||||
- Response: \`verified: false\`, \`linked: true\`
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
- **Optional JWT Bearer Token** (OptionalJwtGuard)
|
||||
- Token present → Links to user account
|
||||
- No token → Saves as guest (requires deviceId)
|
||||
|
||||
---
|
||||
|
||||
### Benefits
|
||||
- Single endpoint for all scenarios
|
||||
- Auto-detects passenger type and flow
|
||||
- Graceful fallback if Fayda fails
|
||||
- Consistent response structure
|
||||
|
||||
---
|
||||
|
||||
### Replaces
|
||||
- Manual verification + save flows`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'International passenger profile saved successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid passport details' })
|
||||
registerInternational(@Body() dto: RegisterInternationalPassengerDto) {
|
||||
return this.service.registerInternational(dto);
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Passenger registered successfully',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid-123',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1985-03-15T00:00:00.000Z',
|
||||
nationality: 'Ethiopian',
|
||||
verified: true,
|
||||
linked: false,
|
||||
message: 'Passenger details saved for guest booking'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Validation error or verification failed',
|
||||
schema: {
|
||||
example: {
|
||||
statusCode: 400,
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Invalid JWT token (only if token provided but invalid)'
|
||||
})
|
||||
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
||||
const userId = req.user?.userId;
|
||||
return this.service.registerPassenger({ ...dto, userId });
|
||||
}
|
||||
|
||||
@Post('save-details')
|
||||
@ApiOperation({
|
||||
summary: '[LEGACY] Save all passenger details before seat selection',
|
||||
description: `**Note:** This endpoint is legacy. Consider using \`POST /passengers/register\` instead.
|
||||
|
||||
Saves all passenger details to database before proceeding to seat selection.
|
||||
|
||||
- Required step in booking flow
|
||||
- Saves details for all passengers in booking
|
||||
- Supports both logged-in users and guests
|
||||
- Prevents data loss if user navigates away
|
||||
|
||||
**Migration:** Use \`POST /passengers/register\` for new implementations.`,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Passenger details saved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
count: 2,
|
||||
passengerIds: ['uuid-1', 'uuid-2'],
|
||||
passengers: [
|
||||
{
|
||||
id: 'uuid-1',
|
||||
passengerName: 'John Doe',
|
||||
dateOfBirth: '1990-01-01T00:00:00.000Z',
|
||||
nationality: 'Ethiopian'
|
||||
},
|
||||
{
|
||||
id: 'uuid-2',
|
||||
passengerName: 'Jane Doe',
|
||||
dateOfBirth: '1992-05-15T00:00:00.000Z',
|
||||
nationality: 'Ethiopian'
|
||||
}
|
||||
],
|
||||
message: 'Passenger details saved successfully'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Validation error - passengers array required' })
|
||||
savePassengers(@Body() body: any) {
|
||||
const passengers = body.passengers || (Array.isArray(body) ? body : [body]);
|
||||
return this.service.savePassengers(passengers, body.userId, body.deviceId);
|
||||
}
|
||||
|
||||
@Post('traveler-profiles')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator';
|
||||
import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTravelerProfileDto {
|
||||
@@ -19,7 +19,15 @@ export class CreateSavedRouteDto {
|
||||
}
|
||||
|
||||
export class VerifyFaydaDto {
|
||||
@ApiProperty({ example: 'ET123456789', description: 'Ethiopian national ID number' })
|
||||
@ApiProperty({
|
||||
example: 'ET123456789',
|
||||
description: `**Ethiopian national ID number**
|
||||
|
||||
- Format: Varies by Ethiopian ID system
|
||||
- Example: ET123456789
|
||||
- Must be valid Ethiopian national ID
|
||||
- Used to query Verifayda 2.0 government database`
|
||||
})
|
||||
@IsString()
|
||||
nationalId: string;
|
||||
}
|
||||
@@ -66,3 +74,184 @@ export class RegisterInternationalPassengerDto {
|
||||
@IsString()
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export class SavePassengerDetailsDto {
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: '1985-03-15' })
|
||||
@IsDateString()
|
||||
dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'ETHIOPIAN' })
|
||||
@IsString()
|
||||
nationality: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'P1234567' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Kenya' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportCountry?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251911234567' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'email@example.com' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
faydaSub?: string;
|
||||
}
|
||||
|
||||
export class SavePassengersDto {
|
||||
@ApiProperty({ type: [SavePassengerDetailsDto] })
|
||||
passengers: SavePassengerDetailsDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'User ID if logged in' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device ID for guest users' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export class RegisterPassengerDto {
|
||||
@ApiProperty({
|
||||
example: 'Abebe Kebede',
|
||||
description: 'Full name of passenger (required for all scenarios)'
|
||||
})
|
||||
@IsString()
|
||||
passengerName: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '1985-03-15',
|
||||
description: 'Date of birth in ISO format YYYY-MM-DD (required for all scenarios)'
|
||||
})
|
||||
@IsDateString()
|
||||
dateOfBirth: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'ET123456789',
|
||||
description: `**Ethiopian national ID number**
|
||||
|
||||
- Triggers automatic Fayda verification if enabled
|
||||
- Use for Ethiopian nationals only
|
||||
- Mutually exclusive with passportNumber
|
||||
- If Fayda enabled: passenger data auto-filled from government database
|
||||
- If Fayda disabled: falls back to manual entry`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'P1234567',
|
||||
description: `**Passport number**
|
||||
|
||||
- Required for international passengers
|
||||
- Mutually exclusive with nationalId
|
||||
- No verification performed (manual entry only)`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Kenya',
|
||||
description: 'Passport issuing country (required if passportNumber provided)'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportCountry?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Ethiopian',
|
||||
description: `**Nationality**
|
||||
|
||||
- Auto-filled if Fayda verification succeeds
|
||||
- Required for international passengers
|
||||
- Optional for Ethiopian passengers (defaults to "Ethiopian")`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '+251911234567',
|
||||
description: 'Phone number in international format (optional but recommended)'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'abebe@example.com',
|
||||
description: 'Email address (optional but recommended)'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `**User ID (auto-populated from JWT token)**
|
||||
|
||||
- Do NOT send this field in request
|
||||
- Automatically extracted from JWT token if present
|
||||
- Used to link passenger to user account`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'device-uuid-123',
|
||||
description: `**Device ID for guest users**
|
||||
|
||||
- **Required if no JWT token provided (guest mode)**
|
||||
- Generate once and store locally (localStorage/AsyncStorage)
|
||||
- Used to retrieve saved passenger profiles
|
||||
- Format: UUID or any unique string`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Male',
|
||||
description: 'Gender (auto-filled if Fayda verification succeeds)'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: true,
|
||||
description: `**Whether to verify with Fayda (auto-determined)**
|
||||
|
||||
- Default: Auto-detect (true if nationalId provided)
|
||||
- Set to false to skip Fayda verification (use manual entry)
|
||||
- Only applicable for Ethiopian nationals with nationalId`
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
verifyWithFayda?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
@@ -11,7 +12,10 @@ interface PassengerFilters {
|
||||
|
||||
@Injectable()
|
||||
export class PassengersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
@@ -126,29 +130,43 @@ export class PassengersService {
|
||||
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
|
||||
}
|
||||
|
||||
async registerInternational(dto: RegisterInternationalPassengerDto) {
|
||||
const profile = await this.prisma.savedPassengerProfile.create({
|
||||
data: {
|
||||
userId: dto.userId,
|
||||
deviceId: dto.deviceId,
|
||||
passengerName: dto.passengerName,
|
||||
dateOfBirth: new Date(dto.dateOfBirth),
|
||||
idDocumentType: 'PASSPORT',
|
||||
passportNumber: dto.passportNumber,
|
||||
passportCountry: dto.passportCountry,
|
||||
nationality: dto.nationality,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
},
|
||||
});
|
||||
async savePassengers(passengers: any[], userId?: string, deviceId?: string) {
|
||||
if (!passengers || !Array.isArray(passengers)) {
|
||||
throw new BadRequestException('Passengers array is required');
|
||||
}
|
||||
|
||||
if (passengers.length === 0) {
|
||||
throw new BadRequestException('At least one passenger is required');
|
||||
}
|
||||
|
||||
const savedProfiles = await Promise.all(
|
||||
passengers.map((p) =>
|
||||
this.prisma.savedPassengerProfile.create({
|
||||
data: {
|
||||
userId,
|
||||
deviceId,
|
||||
passengerName: p.name,
|
||||
dateOfBirth: new Date(p.dateOfBirth),
|
||||
idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
nationality: p.nationality,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
return {
|
||||
id: profile.id,
|
||||
passengerName: profile.passengerName,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
passportNumber: profile.passportNumber,
|
||||
passportCountry: profile.passportCountry,
|
||||
nationality: profile.nationality,
|
||||
message: 'International passenger profile saved successfully',
|
||||
count: savedProfiles.length,
|
||||
passengerIds: savedProfiles.map(p => p.id),
|
||||
passengers: savedProfiles.map(p => ({
|
||||
id: p.id,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
nationality: p.nationality,
|
||||
})),
|
||||
message: 'Passenger details saved successfully',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,4 +179,95 @@ export class PassengersService {
|
||||
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
|
||||
|
||||
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
|
||||
}
|
||||
|
||||
async registerPassenger(dto: RegisterPassengerDto) {
|
||||
const isEthiopian = !!dto.nationalId;
|
||||
const isLoggedIn = !!dto.userId;
|
||||
let verifiedData: any = null;
|
||||
|
||||
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
|
||||
if (isEthiopian && dto.verifyWithFayda !== false) {
|
||||
try {
|
||||
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
|
||||
if (verification.verified && verification.passengerData) {
|
||||
verifiedData = verification.passengerData;
|
||||
}
|
||||
} catch (error) {
|
||||
// If verification fails, continue with manual data
|
||||
console.warn('Fayda verification failed, using manual data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use verified data if available, otherwise use provided data
|
||||
const finalData = {
|
||||
passengerName: verifiedData?.fullName || dto.passengerName,
|
||||
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
|
||||
nationality: verifiedData?.nationality || dto.nationality || (isEthiopian ? 'Ethiopian' : null),
|
||||
gender: verifiedData?.gender || dto.gender,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
// If logged in, update user profile and link passenger
|
||||
if (isLoggedIn) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
include: { passenger: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
// Update user record if not already verified
|
||||
if (!user.faydaVerified && verifiedData) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: dto.userId },
|
||||
data: {
|
||||
fullName: finalData.passengerName,
|
||||
nationality: finalData.nationality,
|
||||
nationalId: dto.nationalId,
|
||||
passportNumber: dto.passportNumber,
|
||||
faydaVerified: !!verifiedData,
|
||||
faydaVerifiedAt: verifiedData ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.passenger?.id || user.id,
|
||||
passengerName: finalData.passengerName,
|
||||
dateOfBirth: finalData.dateOfBirth,
|
||||
nationality: finalData.nationality,
|
||||
verified: !!verifiedData,
|
||||
linked: true,
|
||||
message: 'Passenger details saved and linked to user account',
|
||||
};
|
||||
}
|
||||
|
||||
// Guest user - save to SavedPassengerProfile
|
||||
const profile = await this.prisma.savedPassengerProfile.create({
|
||||
data: {
|
||||
deviceId: dto.deviceId,
|
||||
passengerName: finalData.passengerName,
|
||||
dateOfBirth: finalData.dateOfBirth,
|
||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
passportNumber: dto.passportNumber,
|
||||
passportCountry: dto.passportCountry,
|
||||
nationality: finalData.nationality,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
passengerName: finalData.passengerName,
|
||||
dateOfBirth: finalData.dateOfBirth,
|
||||
nationality: finalData.nationality,
|
||||
verified: !!verifiedData,
|
||||
linked: false,
|
||||
message: 'Passenger details saved for guest booking',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,10 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Delete related records first
|
||||
// Delete related records first (in dependency order)
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SupportService {
|
||||
|
||||
private getBotReply(text: string): string {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.';
|
||||
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
|
||||
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
|
||||
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
|
||||
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
},
|
||||
images: {
|
||||
unoptimized: true, // Required for static export
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -53,13 +53,17 @@ export default function AgentsPage() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Shifts',
|
||||
onClick: (agent: any) => window.location.href = `/agents/${agent.id}/shifts`,
|
||||
onClick: (agent: any) => {
|
||||
window.location.href = `/agents/${agent.id}/shifts`;
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
label: 'View Commissions',
|
||||
onClick: (agent: any) => window.location.href = `/agents/${agent.id}/commissions`,
|
||||
onClick: (agent: any) => {
|
||||
window.location.href = `/agents/${agent.id}/commissions`;
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
|
||||
@@ -58,7 +58,9 @@ export default function AuditLogsPage() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (log: any) => window.location.href = `/audit/${log.id}`,
|
||||
onClick: (log: any) => {
|
||||
window.location.href = `/audit/${log.id}`;
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function CoachesPage() {
|
||||
columns={columns}
|
||||
data={coaches}
|
||||
actions={actions}
|
||||
isLoading={isLoading}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ export default function DashboardPage() {
|
||||
queryFn: () => dashboardApi.getRevenueChart(30),
|
||||
});
|
||||
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
|
||||
queryKey: ['recent-bookings'],
|
||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
||||
});
|
||||
|
||||
const recentBookings = Array.isArray(recentBookingsData)
|
||||
? recentBookingsData
|
||||
: recentBookingsData?.items || recentBookingsData?.data || [];
|
||||
: recentBookingsData?.items || recentBookingsData?.data || [];
|
||||
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
|
||||
@@ -49,7 +49,7 @@ export default function DashboardPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's what's happening today.</p>
|
||||
<p className="text-muted-foreground mt-1">Hello, welcome back! Here's what's happening today.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import '@/styles/globals.css';
|
||||
import Providers from './providers';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EDR Passenger Back-office',
|
||||
description: 'Ethio-Djibouti Railway Passenger Back-office',
|
||||
@@ -32,7 +29,7 @@ export default function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<body className="font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function LoyaltyPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
data={Array.isArray(data) ? data : (data?.items || [])}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
emptyMessage="No loyalty program found"
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function PassengersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
const actions: any[] = [
|
||||
// TODO: Create passenger detail page
|
||||
// {
|
||||
// label: 'View Details',
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function PaymentsPage() {
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
|
||||
const actions: any[] = [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -57,8 +59,9 @@ export default function PaymentsPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payments found"
|
||||
/>
|
||||
|
||||
@@ -14,12 +14,16 @@ interface RouteStop {
|
||||
stationId: string;
|
||||
sequence: number;
|
||||
distanceKm?: number;
|
||||
distanceFromOrigin?: number;
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||
@@ -65,21 +69,38 @@ export default function RoutesPage() {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
if (stops.length < 2) {
|
||||
alert('Route must have at least 2 stops');
|
||||
if (!originStationId || !destinationStationId) {
|
||||
alert('Please select origin and destination stations');
|
||||
return;
|
||||
}
|
||||
|
||||
const stopsArray = stops.map((stop, idx) => {
|
||||
const stopData: any = {
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 1,
|
||||
};
|
||||
if (idx > 0 && stop.distanceKm) {
|
||||
stopData.distanceKm = stop.distanceKm;
|
||||
}
|
||||
return stopData;
|
||||
});
|
||||
if (originStationId === destinationStationId) {
|
||||
alert('Origin and destination must be different');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort middle stops by distance from origin
|
||||
const sortedMiddleStops = [...stops].sort((a, b) =>
|
||||
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
|
||||
);
|
||||
|
||||
// Calculate distanceKm (distance from previous stop)
|
||||
const stopsArray = [
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
||||
...sortedMiddleStops.map((stop, idx) => {
|
||||
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
|
||||
return {
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 2,
|
||||
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
|
||||
};
|
||||
}),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
|
||||
},
|
||||
];
|
||||
|
||||
const routeData = {
|
||||
code: formData.get('code') as string,
|
||||
@@ -100,7 +121,7 @@ export default function RoutesPage() {
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1 }]);
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
@@ -113,6 +134,20 @@ export default function RoutesPage() {
|
||||
setStops(updated);
|
||||
};
|
||||
|
||||
const generateRouteCode = (originId: string, destId: string) => {
|
||||
if (!originId || !destId) return '';
|
||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
||||
const dest = stations?.items?.find((s: any) => s.id === destId);
|
||||
return origin && dest ? `${origin.code}-${dest.code}` : '';
|
||||
};
|
||||
|
||||
const generateRouteName = (originId: string, destId: string) => {
|
||||
if (!originId || !destId) return '';
|
||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
||||
const dest = stations?.items?.find((s: any) => s.id === destId);
|
||||
return origin && dest ? `${origin.name} - ${dest.name}` : '';
|
||||
};
|
||||
|
||||
const handleDelete = async (route: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
|
||||
await deleteMutation.mutateAsync(route.id);
|
||||
@@ -139,6 +174,35 @@ export default function RoutesPage() {
|
||||
label: 'Edit',
|
||||
onClick: (route: any) => {
|
||||
setEditingRoute(route);
|
||||
const routeStops = route.stops || [];
|
||||
if (routeStops.length >= 2) {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// Calculate cumulative distance for destination
|
||||
let cumulativeDistance = 0;
|
||||
routeStops.forEach((stop: any, idx: number) => {
|
||||
if (idx > 0) {
|
||||
cumulativeDistance += stop.distanceKm || 0;
|
||||
}
|
||||
});
|
||||
setDestinationDistance(cumulativeDistance);
|
||||
|
||||
// Calculate distance from origin for middle stops
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => {
|
||||
let distFromOrigin = 0;
|
||||
for (let i = 1; i <= idx + 1; i++) {
|
||||
distFromOrigin += routeStops[i].distanceKm || 0;
|
||||
}
|
||||
return {
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: distFromOrigin,
|
||||
};
|
||||
});
|
||||
setStops(middleStops);
|
||||
}
|
||||
setShowModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
@@ -163,6 +227,9 @@ export default function RoutesPage() {
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setShowModal(true);
|
||||
}}
|
||||
@@ -172,7 +239,7 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={routes?.items || routes || []}
|
||||
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
@@ -185,12 +252,52 @@ export default function RoutesPage() {
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
}}
|
||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={originStationId}
|
||||
onChange={(e) => setOriginStationId(e.target.value)}
|
||||
required
|
||||
disabled={!!editingRoute}
|
||||
>
|
||||
<option value="">Select Origin</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Destination Station *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={destinationStationId}
|
||||
onChange={(e) => setDestinationStationId(e.target.value)}
|
||||
required
|
||||
disabled={!!editingRoute}
|
||||
>
|
||||
<option value="">Select Destination</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Route Code *</label>
|
||||
@@ -198,9 +305,10 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.code}
|
||||
value={generateRouteCode(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
required
|
||||
placeholder="e.g., ADD-DJI"
|
||||
placeholder="Select stations to generate"
|
||||
disabled={!!editingRoute}
|
||||
/>
|
||||
</div>
|
||||
@@ -210,9 +318,10 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.name}
|
||||
value={generateRouteName(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
required
|
||||
placeholder="e.g., Addis Ababa – Djibouti"
|
||||
placeholder="Select stations to generate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,56 +361,66 @@ export default function RoutesPage() {
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops *</label>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Plus}
|
||||
onClick={addStop}
|
||||
>
|
||||
Add Stop
|
||||
</ActionButton>
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
</div>
|
||||
|
||||
{stops.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground mb-3">No stops added. Click "Add Stop" to begin.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{/* Origin Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1 font-medium">
|
||||
{originStationId ? (
|
||||
<span>
|
||||
{stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'}
|
||||
{' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
0 km
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{/* Intermediate Stops */}
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex gap-2 items-start p-3 bg-muted/50 rounded">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 1}
|
||||
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 2}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={stop.stationId}
|
||||
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder={index === 0 ? 'Origin (0 km)' : 'Distance from previous (km)'}
|
||||
value={stop.distanceKm || ''}
|
||||
onChange={(e) => updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
disabled={index === 0}
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={stop.stationId}
|
||||
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.filter((s: any) =>
|
||||
s.id !== originStationId &&
|
||||
s.id !== destinationStationId &&
|
||||
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
||||
).map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="km"
|
||||
value={stop.distanceFromOrigin || ''}
|
||||
onChange={(e) => updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,6 +431,52 @@ export default function RoutesPage() {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Intermediate Stop Button */}
|
||||
{originStationId && destinationStationId && (
|
||||
<div className="flex justify-center py-2">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Plus}
|
||||
onClick={addStop}
|
||||
>
|
||||
Add Intermediate Stop
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{stops.length + 2}
|
||||
</div>
|
||||
<div className="flex-1 font-medium">
|
||||
{destinationStationId ? (
|
||||
<span>
|
||||
{stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
|
||||
{' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-32">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="km"
|
||||
value={destinationDistance || ''}
|
||||
onChange={(e) => setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +487,9 @@ export default function RoutesPage() {
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -77,6 +77,14 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const removeCoachMutation = useMutation({
|
||||
mutationFn: ({ scheduleId, coachId }: { scheduleId: string; coachId: string }) =>
|
||||
schedulesApi.removeCoachAssignment(scheduleId, coachId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -119,6 +127,12 @@ export default function SchedulesPage() {
|
||||
setShowCoachModal(true);
|
||||
};
|
||||
|
||||
const handleRemoveCoach = async (schedule: any, coachId: string) => {
|
||||
if (confirm('Remove this coach from the schedule?')) {
|
||||
await removeCoachMutation.mutateAsync({ scheduleId: schedule.id, coachId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCoach = (coachId: string) => {
|
||||
setSelectedCoaches(prev => {
|
||||
const exists = prev.find(c => c.coachId === coachId);
|
||||
@@ -157,9 +171,21 @@ export default function SchedulesPage() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => (
|
||||
<Badge key={assignment.id} variant="status" status="CONFIRMED">
|
||||
{assignment.coach?.coachNumber || 'N/A'}
|
||||
</Badge>
|
||||
<div key={assignment.id} className="group relative inline-flex">
|
||||
<Badge variant="status" status="CONFIRMED">
|
||||
{assignment.coach?.coachNumber || 'N/A'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCoach(schedule, assignment.coach.id);
|
||||
}}
|
||||
className="absolute -top-1 -right-1 hidden group-hover:flex items-center justify-center w-4 h-4 bg-destructive text-destructive-foreground rounded-full text-xs"
|
||||
title="Remove coach"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{coachCount > 3 && (
|
||||
<Badge variant="status" status="PENDING">
|
||||
@@ -238,7 +264,7 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function SeatClassesPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { Search, Armchair, Lock, Unlock } from 'lucide-react';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||
const [blockReason, setBlockReason] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
@@ -17,114 +21,85 @@ export default function SeatsPage() {
|
||||
queryFn: () => schedulesApi.getAll(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['seats', selectedSchedule],
|
||||
queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]),
|
||||
const { data: seatMapData, isLoading } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule],
|
||||
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
|
||||
enabled: !!selectedSchedule,
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
},
|
||||
});
|
||||
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
},
|
||||
});
|
||||
|
||||
const seats = Array.isArray(data) ? data : data?.items || data?.data || [];
|
||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'seatNumber',
|
||||
label: 'Seat Number',
|
||||
render: (seat: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
|
||||
<Armchair className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="font-medium">{seat.seatNumber}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coach',
|
||||
label: 'Coach',
|
||||
render: (seat: any) => (
|
||||
<span className="text-sm">{seat.coach?.coachNumber || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seatClass',
|
||||
label: 'Class',
|
||||
render: (seat: any) => {
|
||||
const seatClass = seat.coach?.serviceClass || 'N/A';
|
||||
const colorMap: Record<string, string> = {
|
||||
'ECONOMY_REGULAR': 'edr-badge-info',
|
||||
'ECONOMY_BED': 'edr-badge-warning',
|
||||
'VIP_BED': 'edr-badge-success',
|
||||
};
|
||||
return (
|
||||
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
|
||||
{seatClass.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
label: 'Position',
|
||||
render: (seat: any) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{seat.position || seat.seatPosition || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (seat: any) => {
|
||||
const isBlocked = seat.isBlocked || seat.status === 'BLOCKED';
|
||||
const isBooked = seat.isBooked || seat.status === 'BOOKED';
|
||||
|
||||
if (isBlocked) return <span className="edr-badge edr-badge-danger">Blocked</span>;
|
||||
if (isBooked) return <span className="edr-badge edr-badge-warning">Booked</span>;
|
||||
return <span className="edr-badge edr-badge-success">Available</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
const handleBlock = (seat: any) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowBlockModal(true);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Block',
|
||||
onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }),
|
||||
variant: 'secondary' as const,
|
||||
icon: Lock,
|
||||
show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED',
|
||||
},
|
||||
{
|
||||
label: 'Unblock',
|
||||
onClick: (seat: any) => unblockMutation.mutate(seat.id),
|
||||
variant: 'secondary' as const,
|
||||
icon: Unlock,
|
||||
show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED',
|
||||
},
|
||||
];
|
||||
const handleUnblock = async (seat: any) => {
|
||||
if (confirm('Are you sure you want to unblock this seat?')) {
|
||||
await unblockMutation.mutateAsync(seat.id);
|
||||
}
|
||||
};
|
||||
|
||||
const submitBlock = async () => {
|
||||
if (!blockReason.trim()) {
|
||||
alert('Please provide a reason for blocking');
|
||||
return;
|
||||
}
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||
};
|
||||
|
||||
const getSeatStatus = (seat: any) => {
|
||||
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
||||
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
||||
if (seat.status === 'HELD') return 'HELD';
|
||||
return 'AVAILABLE';
|
||||
};
|
||||
|
||||
const getSeatColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'AVAILABLE': return 'bg-green-500';
|
||||
case 'BOOKED': return 'bg-red-500';
|
||||
case 'HELD': return 'bg-yellow-500';
|
||||
case 'BLOCKED': return 'bg-gray-500';
|
||||
default: return 'bg-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCoaches = coaches.filter((coach: any) =>
|
||||
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage seat availability and blocking</p>
|
||||
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
@@ -133,41 +108,191 @@ export default function SeatsPage() {
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeCode = schedule.route?.code || '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} - {routeCode} - {date}
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search seats..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
<label className="label">Search Coaches</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by coach number..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSchedule ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={seats}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
{!selectedSchedule ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Select a schedule to view seats
|
||||
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a schedule to view seat map</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : filteredCoaches.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No coaches found for this schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-500"></div>
|
||||
<span className="text-sm">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-500"></div>
|
||||
<span className="text-sm">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-yellow-500"></div>
|
||||
<span className="text-sm">Held</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gray-500"></div>
|
||||
<span className="text-sm">Blocked</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coaches */}
|
||||
{filteredCoaches.map((coach: any) => {
|
||||
const seats = coach.seats || [];
|
||||
const seatClass = coach.seatClass?.name || 'N/A';
|
||||
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
|
||||
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
|
||||
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Coach {coach.coachNumber} - {coach.label}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{seatClass} • {seats.length} seats
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 text-sm">
|
||||
<span className="text-green-600">Available: {availableCount}</span>
|
||||
<span className="text-red-600">Booked: {bookedCount}</span>
|
||||
<span className="text-gray-600">Blocked: {blockedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{seats.map((seat: any) => {
|
||||
const status = getSeatStatus(seat);
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={seat.id}
|
||||
className="relative group"
|
||||
>
|
||||
<div
|
||||
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
title={`${seat.seatNumber} - ${status}`}
|
||||
>
|
||||
{seat.seatNumber}
|
||||
</div>
|
||||
{(canBlock || canUnblock) && (
|
||||
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
||||
{canBlock && (
|
||||
<button
|
||||
onClick={() => handleBlock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
||||
title="Block seat"
|
||||
>
|
||||
<Lock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
{canUnblock && (
|
||||
<button
|
||||
onClick={() => handleUnblock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
||||
title="Unblock seat"
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Modal */}
|
||||
<Modal
|
||||
isOpen={showBlockModal}
|
||||
onClose={() => {
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
}}
|
||||
title="Block Seat"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
|
||||
<strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Reason for Blocking *</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
placeholder="e.g., Maintenance required, Damaged seat, Reserved for staff"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={submitBlock}
|
||||
loading={blockMutation.isPending}
|
||||
disabled={!blockReason.trim()}
|
||||
>
|
||||
Block Seat
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronUp, ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ActionButton from './ActionButton';
|
||||
@@ -42,6 +43,22 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
}: DataTableProps<T>) {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
const [expandedActions, setExpandedActions] = useState<string | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const isButtonClick = Object.values(buttonRefs.current).some(ref => ref?.contains(target));
|
||||
const isDropdownClick = document.querySelector('[data-dropdown-menu]')?.contains(target);
|
||||
|
||||
if (expandedActions && !isButtonClick && !isDropdownClick) {
|
||||
setExpandedActions(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [expandedActions]);
|
||||
|
||||
// Ensure data is always an array
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
@@ -81,7 +98,7 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('card p-0 overflow-visible', className)}>
|
||||
<div className={cn('card p-0', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
@@ -145,66 +162,45 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="relative">
|
||||
{(() => {
|
||||
const visibleActions = actions.filter(action => !action.show || action.show(item));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visibleActions.length === 1) {
|
||||
const action = visibleActions[0];
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={() => action.onClick(item)}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
{(() => {
|
||||
const visibleActions = actions.filter(action => !action.show || action.show(item));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visibleActions.length === 1) {
|
||||
const action = visibleActions[0];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{expandedActions === item.id && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<div className="py-1">
|
||||
{visibleActions.map((action, actionIndex) => (
|
||||
<button
|
||||
key={actionIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(item);
|
||||
setExpandedActions(null);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
)}
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ActionButton
|
||||
onClick={() => action.onClick(item)}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</ActionButton>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => { buttonRefs.current[item.id] = el; }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.right + window.scrollX - 192, // 192px = w-48
|
||||
});
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@@ -218,6 +214,47 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedActions && dropdownPosition && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
data-dropdown-menu
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="py-1">
|
||||
{actions
|
||||
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
|
||||
.map((action, actionIndex) => {
|
||||
const item = sortedData.find(item => item.id === expandedActions);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<button
|
||||
key={actionIndex}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpandedActions(null);
|
||||
action.onClick(item);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
)}
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const bookingsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/bookings${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
@@ -24,7 +24,7 @@ export const passengersApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/passengers${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
@@ -40,7 +40,7 @@ export const stationsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/stations${query ? `?${query}` : ''}`);
|
||||
// Handle wrapped response: { success, data: [...], timestamp }
|
||||
@@ -60,7 +60,7 @@ export const fleetApi = {
|
||||
getTrains: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/fleet/trains${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
@@ -71,7 +71,7 @@ export const fleetApi = {
|
||||
getCoaches: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/fleet/coaches${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
@@ -90,7 +90,7 @@ export const fleetApi = {
|
||||
// Schedules API
|
||||
export const schedulesApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/schedules${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -111,6 +111,10 @@ export const schedulesApi = {
|
||||
|
||||
// Seats API
|
||||
export const seatsApi = {
|
||||
getSeatMap: (scheduleId: string, coachId?: string) => {
|
||||
const params = coachId ? `?coachId=${coachId}` : '';
|
||||
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
|
||||
},
|
||||
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
|
||||
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
@@ -121,7 +125,7 @@ export const seatsApi = {
|
||||
// Payments API
|
||||
export const paymentsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/payments${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -136,7 +140,7 @@ export const paymentsApi = {
|
||||
// Tickets API
|
||||
export const ticketsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -151,7 +155,7 @@ export const ticketsApi = {
|
||||
// Agents API
|
||||
export const agentsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/agents${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -170,7 +174,7 @@ export const agentsApi = {
|
||||
// Loyalty API
|
||||
export const loyaltyApi = {
|
||||
getAccounts: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/loyalty/accounts${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -186,7 +190,7 @@ export const loyaltyApi = {
|
||||
// Wallet API
|
||||
export const walletApi = {
|
||||
getAccounts: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/wallet/accounts${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -201,7 +205,7 @@ export const walletApi = {
|
||||
// Promotions API
|
||||
export const promotionsApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/promos${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -217,7 +221,7 @@ export const promotionsApi = {
|
||||
// Support API
|
||||
export const supportApi = {
|
||||
getConversations: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/support/conversations${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -238,7 +242,7 @@ export const notificationsApi = {
|
||||
updateTemplate: (id: string, data: any) => apiClient.patch<any>(`/notifications/templates/${id}`, data),
|
||||
send: (data: any) => apiClient.post<any>('/notifications/send', data),
|
||||
getHistory: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/notifications/history${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -250,7 +254,7 @@ export const notificationsApi = {
|
||||
// Fraud API
|
||||
export const fraudApi = {
|
||||
getAlerts: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/fraud/alerts${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -266,7 +270,7 @@ export const fraudApi = {
|
||||
// Verifayda API
|
||||
export const verifaydaApi = {
|
||||
getVerifications: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/passengers/verifications${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -280,7 +284,7 @@ export const verifaydaApi = {
|
||||
// Audit API
|
||||
export const auditApi = {
|
||||
getLogs: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/audit/logs${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -312,7 +316,7 @@ export const foodApi = {
|
||||
getCategories: () => apiClient.get<any[]>('/food/categories'),
|
||||
getMenuItems: (scheduleId: string) => apiClient.get<any[]>(`/food/menu/${scheduleId}`),
|
||||
getOrders: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/food/orders${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
@@ -326,7 +330,7 @@ export const foodApi = {
|
||||
// Reports API
|
||||
export const reportsApi = {
|
||||
getOperationalReports: async (params?: any) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/reports/operational${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,20 @@ module.exports = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: true, // Required for static export
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
useNavigate,
|
||||
useLocation,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||
|
||||
import TicketsPage from "./pages/tickets/TicketsPage";
|
||||
import TicketDetailPage from "./pages/tickets/TicketDetailPage";
|
||||
import BookTicketPage from "./pages/tickets/BookTicketPage";
|
||||
import SchedulesPage from "./pages/schedules/SchedulesPage";
|
||||
import ScheduleDetailPage from "./pages/schedules/ScheduleDetailPage";
|
||||
import StationsPage from "./pages/stations/StationsPage";
|
||||
import PassengersPage from "./pages/passengers/PassengersPage";
|
||||
import DashboardPage from "./pages/dashboard/DashboardPage";
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: "Dashboard", href: "/" },
|
||||
{ label: "Tickets", href: "/tickets" },
|
||||
{ label: "Schedules", href: "/schedules" },
|
||||
{ label: "Stations", href: "/stations" },
|
||||
{ label: "Passengers", href: "/passengers" },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="EDR Passenger"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/new" element={<BookTicketPage />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
<Route path="/schedules/:id" element={<ScheduleDetailPage />} />
|
||||
<Route path="/stations" element={<StationsPage />} />
|
||||
<Route path="/passengers" element={<PassengersPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -18,7 +20,7 @@ export default function ConfirmationPage() {
|
||||
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
|
||||
});
|
||||
|
||||
const { data: booking } = useQuery({
|
||||
const { data: _booking } = useQuery({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -75,22 +77,22 @@ export default function ConfirmationPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<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-5xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600" />
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-green-600 mb-2">Booking Confirmed!</h1>
|
||||
<p className="text-gray-600 text-lg">Your train tickets are ready</p>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking Confirmed!</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
</div>
|
||||
|
||||
{/* PNR Card */}
|
||||
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 text-white">
|
||||
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-sm opacity-90 mb-2">Booking Reference (PNR)</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
@@ -114,44 +116,44 @@ export default function ConfirmationPage() {
|
||||
{/* Trip Summary */}
|
||||
<div className="card mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-primary" />
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Trip Details</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Train Number</p>
|
||||
<p className="font-semibold text-lg">{selectedSchedule?.trainNumber}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Train Number</p>
|
||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Route</p>
|
||||
<p className="font-semibold text-lg">{selectedSchedule?.origin} → {selectedSchedule?.destination}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
|
||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} → {selectedSchedule?.destination}</p>
|
||||
</div>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Class</p>
|
||||
<p className="font-semibold">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Departure</p>
|
||||
<p className="font-semibold">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Arrival</p>
|
||||
<p className="font-semibold">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Duration</p>
|
||||
<p className="font-semibold">{selectedSchedule?.duration}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule?.duration}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +161,7 @@ export default function ConfirmationPage() {
|
||||
|
||||
{/* Tickets */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold mb-4">Your Tickets</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your Tickets</h2>
|
||||
<div className="space-y-4">
|
||||
{passengers.map((passenger, index) => {
|
||||
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||
@@ -178,47 +180,47 @@ export default function ConfirmationPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{passenger.name}</h3>
|
||||
<p className="text-sm text-gray-600">Passenger {index + 1}</p>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
||||
</div>
|
||||
<span className="badge badge-success">CONFIRMED</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600">Ticket Number</p>
|
||||
<p className="font-semibold">{ticketNumber}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">Date of Birth</p>
|
||||
<p className="font-semibold">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">Nationality</p>
|
||||
<p className="font-semibold">{passenger.nationality}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">Seat</p>
|
||||
<p className="font-semibold">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatId ? 'Assigned' : 'Will be assigned'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-xs text-yellow-800">
|
||||
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
📱 Show this QR code at the gate for boarding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center justify-center bg-gray-50 rounded-lg p-6">
|
||||
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={160}
|
||||
level="H"
|
||||
includeMargin={true}
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mt-2 text-center">Scan at gate</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center">Scan at gate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,13 +268,13 @@ export default function ConfirmationPage() {
|
||||
|
||||
{/* Info Notices */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
📧 A confirmation email with your tickets has been sent to your registered email address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-sm text-green-800">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm text-green-800 dark:text-green-300">
|
||||
✅ Please arrive at the station at least 30 minutes before departure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
|
||||
|
||||
const passengerSchema = z.object({
|
||||
name: z.string().min(2, 'Name is required'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
gender: z.enum(['Male', 'Female']).optional(),
|
||||
nationality: z.string().min(1, 'Nationality is required'),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
nationalId: z.string().optional(),
|
||||
passportNumber: z.string().optional(),
|
||||
passportCountry: z.string().optional(),
|
||||
passportIssueDate: z.string().optional(),
|
||||
passportExpiryDate: z.string().optional(),
|
||||
passportIssuingAuthority: z.string().optional(),
|
||||
faydaVerified: z.boolean().optional(),
|
||||
faydaSub: z.string().optional(),
|
||||
formExpanded: z.boolean().optional(),
|
||||
}).refine((data) => {
|
||||
// For non-Ethiopian passengers, passport number and country are required
|
||||
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
|
||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
||||
data.passportCountry && data.passportCountry.length > 0;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Passport number and country are required for non-Ethiopian passengers',
|
||||
path: ['passportNumber'],
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -30,22 +50,32 @@ type FormData = z.infer<typeof formSchema>;
|
||||
export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const [verifying, setVerifying] = useState<number | null>(null);
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
const [faydaEnabled, setFaydaEnabled] = useState(true);
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||
const [updatingUser, setUpdatingUser] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||
|
||||
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
passengers: Array.from({ length: totalPassengers }, (_, i) => ({
|
||||
passengers: Array.from({ length: totalPassengers }, () => ({
|
||||
name: '',
|
||||
dateOfBirth: '',
|
||||
gender: undefined,
|
||||
nationality: searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
phone: '',
|
||||
email: '',
|
||||
nationalId: '',
|
||||
passportNumber: '',
|
||||
passportCountry: '',
|
||||
passportIssueDate: '',
|
||||
passportExpiryDate: '',
|
||||
passportIssuingAuthority: '',
|
||||
faydaVerified: false,
|
||||
formExpanded: false,
|
||||
})),
|
||||
createAccount: false,
|
||||
},
|
||||
@@ -54,51 +84,137 @@ export default function PassengersPage() {
|
||||
const { fields } = useFieldArray({ control, name: 'passengers' });
|
||||
const passengers = watch('passengers');
|
||||
|
||||
const verifyFayda = async (index: number) => {
|
||||
const nationalId = passengers[index].nationalId;
|
||||
if (!nationalId) return;
|
||||
|
||||
setVerifying(index);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: undefined as any });
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/passengers/verify-fayda', { nationalId });
|
||||
|
||||
if (response.verified && response.passengerData) {
|
||||
setValue(`passengers.${index}.name`, response.passengerData.fullName);
|
||||
setValue(`passengers.${index}.dateOfBirth`, response.passengerData.dateOfBirth.split('T')[0]);
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.faydaSub`, response.passengerData.faydaSub);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
|
||||
} else {
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'error' });
|
||||
useEffect(() => {
|
||||
const checkFaydaStatus = async () => {
|
||||
try {
|
||||
const response: any = await apiClient.get('/config/fayda-status');
|
||||
setFaydaEnabled(response?.enabled ?? true);
|
||||
} catch {
|
||||
setFaydaEnabled(true);
|
||||
}
|
||||
};
|
||||
checkFaydaStatus();
|
||||
|
||||
if (isAuthenticated && user && searchCriteria?.nationality === 'ETHIOPIAN') {
|
||||
if (user.faydaVerified && user.fullName && user.dateOfBirth) {
|
||||
setValue('passengers.0.name', user.fullName);
|
||||
setValue('passengers.0.dateOfBirth', user.dateOfBirth);
|
||||
setValue('passengers.0.gender', user.gender as any);
|
||||
setValue('passengers.0.nationality', user.nationality || 'ETHIOPIAN');
|
||||
setValue('passengers.0.phone', user.phone || '');
|
||||
setValue('passengers.0.email', user.email || '');
|
||||
setValue('passengers.0.faydaVerified', true);
|
||||
setValue('passengers.0.faydaSub', user.faydaSub || '');
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
setVerificationStatus({ 0: 'success' });
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return; // Guard for SSR
|
||||
|
||||
const faydaUrl = process.env.NEXT_PUBLIC_FAYDA_URL || 'https://fayda.gov.et/verify';
|
||||
const callbackUrl = `${window.location.origin}/booking/passengers?faydaCallback=${index}`;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
window.open(
|
||||
`${faydaUrl}?callback=${encodeURIComponent(callbackUrl)}`,
|
||||
'FaydaVerification',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data?.type === 'FAYDA_VERIFIED' && event.data?.index === index) {
|
||||
const data = event.data.passengerData;
|
||||
setValue(`passengers.${index}.name`, data.fullName);
|
||||
setValue(`passengers.${index}.dateOfBirth`, data.dateOfBirth.split('T')[0]);
|
||||
setValue(`passengers.${index}.gender`, data.gender);
|
||||
setValue(`passengers.${index}.nationality`, data.nationality || 'ETHIOPIAN');
|
||||
setValue(`passengers.${index}.phone`, data.phone || '');
|
||||
setValue(`passengers.${index}.email`, data.email || '');
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.faydaSub`, data.faydaSub);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
|
||||
|
||||
if (index === 0 && isAuthenticated && user) {
|
||||
setUpdatingUser(true);
|
||||
try {
|
||||
await apiClient.patch('/auth/profile', {
|
||||
fullName: data.fullName,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
gender: data.gender,
|
||||
nationality: data.nationality || 'ETHIOPIAN',
|
||||
phone: data.phone,
|
||||
faydaVerified: true,
|
||||
faydaSub: data.faydaSub,
|
||||
});
|
||||
updateUser({
|
||||
fullName: data.fullName,
|
||||
dateOfBirth: data.dateOfBirth.split('T')[0],
|
||||
gender: data.gender,
|
||||
nationality: data.nationality || 'ETHIOPIAN',
|
||||
phone: data.phone,
|
||||
faydaVerified: true,
|
||||
faydaSub: data.faydaSub,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
} finally {
|
||||
setUpdatingUser(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleMessage);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
};
|
||||
|
||||
const toggleForm = (index: number) => {
|
||||
setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
...p,
|
||||
isPrimaryPassenger: i === 0,
|
||||
}));
|
||||
|
||||
// Save passenger details to database before proceeding
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
|
||||
await apiClient.post('/passengers/save-details', {
|
||||
passengers: passengerDetails,
|
||||
userId: user?.id,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
router.push('/booking/seats');
|
||||
} catch (error) {
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'error' });
|
||||
console.error('Failed to save passenger details:', error);
|
||||
alert('Failed to save passenger details. Please try again.');
|
||||
} finally {
|
||||
setVerifying(null);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
...p,
|
||||
isPrimaryPassenger: i === 0,
|
||||
}));
|
||||
|
||||
setPassengers(passengerDetails);
|
||||
setCreateAccount(data.createAccount);
|
||||
router.push('/booking/seats');
|
||||
};
|
||||
|
||||
if (!searchCriteria) {
|
||||
console.log('No search criteria, redirecting to search');
|
||||
router.push('/booking/search');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Search criteria:', searchCriteria);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -108,8 +224,13 @@ export default function PassengersPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN';
|
||||
const isVerified = passengers[index]?.faydaVerified;
|
||||
const isFormExpanded = passengers[index]?.formExpanded;
|
||||
const status = verificationStatus[index];
|
||||
const isPrimaryPassenger = index === 0;
|
||||
const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified;
|
||||
const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified;
|
||||
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified;
|
||||
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded;
|
||||
|
||||
return (
|
||||
<div key={field.id} className="card">
|
||||
@@ -121,117 +242,266 @@ export default function PassengersPage() {
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{showVerifyButton ? (
|
||||
<div className="text-center py-8">
|
||||
{isLoggedInNotVerified && (
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Please verify your identity with Fayda to complete your profile
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFaydaVerification(index)}
|
||||
className="btn-primary flex items-center justify-center gap-2 mx-auto"
|
||||
disabled={updatingUser}
|
||||
>
|
||||
{updatingUser ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
)}
|
||||
{updatingUser ? 'Updating Profile...' : 'Verify with Fayda'}
|
||||
</button>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3">
|
||||
Click to verify your Ethiopian national ID
|
||||
</p>
|
||||
{!isLoggedInNotVerified && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="text-sm text-primary hover:underline mt-2"
|
||||
>
|
||||
Or enter details manually
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : showManualEntryLink ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Fayda verification is currently unavailable
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="btn-primary"
|
||||
>
|
||||
Enter Details Manually
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isEthiopian ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">National ID</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
{...register(`passengers.${index}.nationalId`)}
|
||||
className="input-field"
|
||||
placeholder="ET123456789"
|
||||
disabled={isVerified}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => verifyFayda(index)}
|
||||
disabled={verifying === index || isVerified}
|
||||
className="btn-primary whitespace-nowrap"
|
||||
>
|
||||
{verifying === index ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isVerified ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda
|
||||
</p>
|
||||
</div>
|
||||
{status === 'success' && (
|
||||
<p className="text-green-600 dark:text-green-400 text-sm mt-1 flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" /> Verified successfully
|
||||
</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-1 flex items-center gap-1">
|
||||
<XCircle className="w-4 h-4" /> Verification failed. You can continue manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
disabled={isVerified}
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
disabled={isVerified}
|
||||
/>
|
||||
{errors.passengers?.[index]?.dateOfBirth && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.passengers?.[index]?.dateOfBirth && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.nationality`)}
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
placeholder="+251911234567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per passport"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
placeholder="Full name as per passport"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.passengers?.[index]?.dateOfBirth && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.nationality`)}
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
placeholder="+254712345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
/>
|
||||
{errors.passengers?.[index]?.dateOfBirth && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t dark:border-gray-700 pt-4 mt-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportNumber`)}
|
||||
className="input-field"
|
||||
placeholder="P1234567"
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportNumber && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportNumber`)}
|
||||
className="input-field"
|
||||
placeholder="P1234567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
className="input-field"
|
||||
placeholder="Djibouti"
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportCountry && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Country</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
className="input-field"
|
||||
placeholder="Kenya"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Authority</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportIssuingAuthority`)}
|
||||
className="input-field"
|
||||
placeholder="Government of Djibouti"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issue Date</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Expiry Date</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -244,11 +514,11 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1">
|
||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
Continue to Seat Selection
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Continue to Seat Selection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react';
|
||||
|
||||
// Mock payment methods with Ethiopian providers
|
||||
@@ -132,36 +132,56 @@ export default function PaymentPage() {
|
||||
});
|
||||
};
|
||||
|
||||
if (!bookingId || !pnr) {
|
||||
router.push('/booking/search');
|
||||
return null;
|
||||
// Redirect if no booking data (but not during navigation)
|
||||
useEffect(() => {
|
||||
// Add a small delay to allow state to be set from previous page
|
||||
const timer = setTimeout(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
console.log('Payment page: Missing booking data, redirecting to search');
|
||||
console.log('bookingId:', bookingId, 'pnr:', pnr);
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
if (!bookingId && !pnr) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 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">Loading payment details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<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-3xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Complete Payment</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete Payment</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Booking Reference: <span className="font-bold text-primary">{pnr}</span>
|
||||
</p>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
{isProcessing && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-8 max-w-md text-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md text-center">
|
||||
{paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Payment Successful!</h3>
|
||||
<p className="text-gray-600 mb-4">Generating your tickets...</p>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment Successful!</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Processing Payment</h3>
|
||||
<p className="text-gray-600">Please wait while we process your payment...</p>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing Payment</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,29 +190,29 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Order Summary</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order Summary</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Route</span>
|
||||
<span className="font-medium">{selectedSchedule?.origin} → {selectedSchedule?.destination}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} → {selectedSchedule?.destination}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Train</span>
|
||||
<span className="font-medium">{selectedSchedule?.trainNumber}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</span>
|
||||
</div>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Class</span>
|
||||
<span className="font-medium">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Class</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Passengers</span>
|
||||
<span className="font-medium">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Passengers</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total Amount</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">Total Amount</span>
|
||||
<span className="text-primary">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
@@ -203,7 +223,7 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select Payment Method</h2>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const Icon = method.icon;
|
||||
@@ -215,19 +235,19 @@ export default function PaymentPage() {
|
||||
disabled={isProcessing}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary-50 shadow-md'
|
||||
: method.color
|
||||
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-md'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary'
|
||||
} ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
isSelected ? 'bg-primary' : 'bg-white'
|
||||
isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'
|
||||
}`}>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900">{method.name}</p>
|
||||
<p className="text-sm text-gray-600">{method.description}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{method.description}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||
@@ -271,16 +291,16 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Error Message */}
|
||||
{paymentMutation.isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
|
||||
<p className="text-red-800 text-sm font-medium">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
|
||||
⚠️ Payment failed. Please try again or contact support if the problem persists.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="mt-6 p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-xs text-gray-600 text-center">
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
|
||||
🔒 Your payment is secure and encrypted. We do not store your payment information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function ResultsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center"><p className="text-gray-600 dark:text-gray-400">Loading...</p></div>}>{children}</Suspense>;
|
||||
}
|
||||
@@ -39,8 +39,11 @@ export default function ResultsPage() {
|
||||
|
||||
const { data: results, isLoading, error } = useQuery<Schedule[]>({
|
||||
queryKey: ['search', searchData],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.post('/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);
|
||||
return response;
|
||||
},
|
||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||
@@ -87,8 +90,8 @@ export default function ResultsPage() {
|
||||
trainNumber: schedule.trainNumber,
|
||||
origin: schedule.origin?.name || 'Origin',
|
||||
destination: schedule.destination?.name || 'Destination',
|
||||
departureTime: schedule.departureAt || schedule.departureTime,
|
||||
arrivalTime: schedule.arrivalAt || schedule.arrivalTime,
|
||||
departureTime: schedule.departureAt || schedule.departureTime || '',
|
||||
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
|
||||
duration: durationStr,
|
||||
baseFareAdult: selectedClassFare.baseFareMinor,
|
||||
baseFareChild: selectedClassFare.baseFareMinor,
|
||||
@@ -137,7 +140,7 @@ export default function ResultsPage() {
|
||||
</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't find any trains matching your search criteria. Try adjusting your dates or route.
|
||||
We couldn't find any trains matching your search criteria. Try adjusting your dates or route.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
|
||||
Modify Search
|
||||
@@ -232,7 +235,7 @@ export default function ResultsPage() {
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{schedule.stops.length} stops</span>
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function ReviewPage() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount } = useBookingStore();
|
||||
const [timeLeft, setTimeLeft] = useState<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
@@ -33,22 +34,70 @@ export default function ReviewPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [seatHold]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSeatDetails = async () => {
|
||||
if (!selectedSchedule?.id) return;
|
||||
|
||||
try {
|
||||
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
const details: Record<string, string> = {};
|
||||
passengers.forEach(p => {
|
||||
if (p.seatId) {
|
||||
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
||||
if (seat) {
|
||||
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
}
|
||||
});
|
||||
setSeatDetails(details);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch seat details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSeatDetails();
|
||||
}, [selectedSchedule?.id, passengers]);
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/bookings/guest', data),
|
||||
onSuccess: (data: any) => {
|
||||
setBookingId(data.bookingId || data.id);
|
||||
setPNR(data.pnr || data.bookingReference);
|
||||
console.log('Booking created successfully:', data);
|
||||
const bookingIdValue = data.bookingId || data.id;
|
||||
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
|
||||
|
||||
console.log('Setting booking ID:', bookingIdValue);
|
||||
console.log('Setting PNR:', pnrValue);
|
||||
|
||||
setBookingId(bookingIdValue);
|
||||
setPNR(pnrValue);
|
||||
|
||||
// Check if payment is required
|
||||
const totalAmount = data.totalMinor || data.totalAmount || 0;
|
||||
|
||||
if (totalAmount > 0) {
|
||||
// Redirect to payment page
|
||||
router.push('/booking/payment');
|
||||
} else {
|
||||
// No payment required, go directly to confirmation
|
||||
router.push('/booking/confirmation');
|
||||
}
|
||||
console.log('Total amount:', totalAmount);
|
||||
console.log('Booking store after update:', useBookingStore.getState());
|
||||
|
||||
// Use setTimeout to ensure state updates complete before navigation
|
||||
setTimeout(() => {
|
||||
// Verify state was set
|
||||
const currentState = useBookingStore.getState();
|
||||
console.log('Current booking store state:', currentState);
|
||||
console.log('bookingId:', currentState.bookingId);
|
||||
console.log('pnr:', currentState.pnr);
|
||||
|
||||
if (totalAmount > 0) {
|
||||
// Redirect to payment page
|
||||
console.log('Redirecting to payment page');
|
||||
router.push('/booking/payment');
|
||||
} else {
|
||||
// No payment required, go directly to confirmation
|
||||
console.log('Redirecting to confirmation page');
|
||||
router.push('/booking/confirmation');
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Booking creation failed:', error);
|
||||
@@ -58,11 +107,18 @@ export default function ReviewPage() {
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
console.log('handleConfirm called');
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
console.log('Search criteria:', searchCriteria);
|
||||
console.log('Seat hold:', seatHold);
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
// Validate that we have a hold
|
||||
if (!seatHold?.holdId) {
|
||||
console.error('No seat hold found');
|
||||
alert('Please select seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
@@ -70,6 +126,7 @@ export default function ReviewPage() {
|
||||
|
||||
// Validate search criteria
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
alert('Missing search criteria. Please start over.');
|
||||
router.push('/booking/search');
|
||||
return;
|
||||
@@ -79,6 +136,7 @@ export default function ReviewPage() {
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
console.log('Seat classes:', seatClasses);
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
}
|
||||
@@ -93,58 +151,88 @@ export default function ReviewPage() {
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
displayCurrency: 'ETB' as const,
|
||||
passengers: passengers.map(p => ({
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: p.nationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
|
||||
idDocumentNumber: p.nationalId || p.passportNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
nationality: p.nationality,
|
||||
})),
|
||||
createAccount: createAccount,
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
const hasNationalId = isEthiopian && p.nationalId;
|
||||
|
||||
return {
|
||||
seatId: p.seatId || '',
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
idDocumentType: hasNationalId ? 'NATIONAL_ID' as const : 'PASSPORT' as const,
|
||||
idDocumentNumber: p.nationalId || undefined,
|
||||
passportNumber: !hasNationalId ? p.passportNumber : undefined,
|
||||
passportCountry: !hasNationalId ? p.passportCountry : undefined,
|
||||
nationality: p.nationality,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
};
|
||||
}),
|
||||
createAccount: createAccount || false,
|
||||
savePassengerDetails: true,
|
||||
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
|
||||
};
|
||||
|
||||
// Save deviceId for future use
|
||||
if (typeof window !== 'undefined' && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId!);
|
||||
if (typeof window !== 'undefined' && bookingData.deviceId && !localStorage.getItem('deviceId')) {
|
||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||
}
|
||||
|
||||
console.log('Creating booking with payload:', bookingData);
|
||||
createBookingMutation.mutate(bookingData);
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
alert('An unexpected error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (typeof window !== 'undefined') {
|
||||
router.push('/booking/search');
|
||||
// Only redirect to search if we're not in the middle of creating a booking
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
|
||||
console.log('Redirecting to search - missing data');
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}
|
||||
}, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debug: Log selected schedule data
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Base fare adult:', selectedSchedule.baseFareAdult);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
// Calculate fare - use the fare from selected schedule or from fare breakdown
|
||||
const baseFare = passengers.reduce((sum, p, i) => {
|
||||
const isChild = i >= (passengers.length - (passengers.filter(p => p.dateOfBirth).length));
|
||||
const isFreeChild = isChild && i === passengers.length - 1;
|
||||
return sum + (isFreeChild ? 0 : selectedSchedule.baseFareAdult);
|
||||
// Get the fare per passenger from the schedule
|
||||
const farePerPassenger = selectedSchedule.baseFareAdult ||
|
||||
(selectedSchedule as any).fareAdult ||
|
||||
(selectedSchedule as any).price ||
|
||||
0;
|
||||
|
||||
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
|
||||
|
||||
// For now, charge all passengers the same fare
|
||||
// TODO: Implement proper age-based pricing when we have dateOfBirth
|
||||
return sum + farePerPassenger;
|
||||
}, 0);
|
||||
|
||||
console.log('Calculated base fare:', baseFare);
|
||||
|
||||
const total = baseFare;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<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-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Review Your Booking</h1>
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review Your Booking</h1>
|
||||
|
||||
{seatHold && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
⏱️ Your seats will be released in: <span className="font-bold">{timeLeft}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -152,49 +240,49 @@ export default function ReviewPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Trip Details</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Train</span>
|
||||
<span className="font-medium">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.trainNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Route</span>
|
||||
<span className="font-medium">{selectedSchedule.origin} → {selectedSchedule.destination}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.origin} → {selectedSchedule.destination}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Departure</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-gray-600 dark:text-gray-400">Departure</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Arrival</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-gray-600 dark:text-gray-400">Arrival</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Duration</span>
|
||||
<span className="font-medium">{selectedSchedule.duration}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Passengers</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Passengers</h2>
|
||||
<div className="space-y-3">
|
||||
{passengers.map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center border-b pb-2 last:border-0">
|
||||
<div key={i} className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||
<div>
|
||||
<p className="font-medium">{p.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">Seat</p>
|
||||
<p className="font-medium">{p.seatId ? 'Selected' : 'Auto-assign'}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -202,15 +290,15 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Fare Breakdown</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare Breakdown</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Base Fare</span>
|
||||
<span>ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Base Fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span>Total</span>
|
||||
<span className="text-primary">ETB {(total / 100).toFixed(2)}</span>
|
||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,8 +317,8 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
|
||||
{createBookingMutation.isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
|
||||
<p className="text-red-800 text-sm">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">
|
||||
⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center"><p className="text-gray-600 dark:text-gray-400">Loading...</p></div>}>{children}</Suspense>;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
import { Train, MapPin, Calendar, Users, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
|
||||
import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function SearchPage() {
|
||||
|
||||
const { data: stations, isLoading, error } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/stations');
|
||||
queryFn: async (): Promise<Station[]> => {
|
||||
const response = await apiClient.get('/stations') as Station[];
|
||||
return response;
|
||||
},
|
||||
});
|
||||
@@ -154,7 +154,7 @@ export default function SearchPage() {
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select departure</option>
|
||||
<option value="">Select departure station</option>
|
||||
{stations?.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
@@ -183,7 +183,7 @@ export default function SearchPage() {
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select arrival</option>
|
||||
<option value="">Select arrival station</option>
|
||||
{stations?.map((s) => (
|
||||
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
|
||||
))}
|
||||
@@ -251,7 +251,7 @@ export default function SearchPage() {
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-600">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
|
||||
<div className="text-xs text-primary-600 dark:text-primary-400"><5 years • First child free</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400"><5 years • First child free</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Seat, Coach } from '@/types';
|
||||
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
|
||||
export default function SeatsPage() {
|
||||
@@ -13,7 +15,7 @@ export default function SeatsPage() {
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||||
const [_timeLeft, _setTimeLeft] = useState<number | null>(null);
|
||||
const [modalState, setModalState] = useState({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
@@ -21,16 +23,25 @@ export default function SeatsPage() {
|
||||
type: 'info' as 'warning' | 'error' | 'success' | 'info',
|
||||
});
|
||||
|
||||
const { data: seatMapData } = useQuery({
|
||||
const { data: seatMapData, isLoading, error } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule?.id],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
|
||||
enabled: !!selectedSchedule?.id,
|
||||
});
|
||||
|
||||
// Debug: Log the seat map data
|
||||
useEffect(() => {
|
||||
if (seatMapData) {
|
||||
console.log('Seat map data:', seatMapData);
|
||||
console.log('Is array?', Array.isArray(seatMapData));
|
||||
console.log('Has coaches?', (seatMapData as any)?.coaches);
|
||||
}
|
||||
}, [seatMapData]);
|
||||
|
||||
const holdMutation = useMutation({
|
||||
mutationFn: async (seatIds: string[]) => {
|
||||
// Create temporary passenger IDs for the hold
|
||||
const passengersForHold = passengers.slice(0, seatIds.length).map((p, i) => ({
|
||||
const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({
|
||||
passengerId: `temp-${Date.now()}-${i}`, // Temporary ID for guest booking
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
@@ -51,16 +62,47 @@ export default function SeatsPage() {
|
||||
});
|
||||
|
||||
// Extract coaches and seats from seat map data
|
||||
const coaches = Array.isArray(seatMapData) ? seatMapData : (seatMapData?.coaches || []);
|
||||
const coaches = (seatMapData as any)?.coaches || [];
|
||||
|
||||
// Debug: Log coaches
|
||||
useEffect(() => {
|
||||
console.log('Coaches:', coaches);
|
||||
console.log('Selected seat class:', selectedSchedule?.selectedSeatClass);
|
||||
if (coaches.length > 0) {
|
||||
console.log('First coach structure:', coaches[0]);
|
||||
console.log('First coach seatClass:', coaches[0]?.seatClass);
|
||||
console.log('First coach coachClass:', coaches[0]?.coachClass);
|
||||
}
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
|
||||
// Filter coaches by selected seat class if available
|
||||
const filteredCoaches = selectedSchedule?.selectedSeatClass
|
||||
? coaches.filter((c: any) => c.seatClass?.name === selectedSchedule.selectedSeatClass || c.coachClass === selectedSchedule.selectedSeatClass)
|
||||
? coaches.filter((c: any) => {
|
||||
// seatClass can be either a string or an object with a name property
|
||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||
console.log('Comparing:', seatClassName, 'with', selectedSchedule.selectedSeatClass);
|
||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||
})
|
||||
: coaches;
|
||||
|
||||
// Debug filtered coaches
|
||||
useEffect(() => {
|
||||
console.log('Filtered coaches:', filteredCoaches);
|
||||
console.log('Filtered coaches count:', filteredCoaches.length);
|
||||
}, [filteredCoaches]);
|
||||
|
||||
const selectedCoachData = filteredCoaches.find((c: any) => c.id === selectedCoach);
|
||||
const seats = selectedCoachData?.seats || [];
|
||||
|
||||
// Debug seats
|
||||
useEffect(() => {
|
||||
console.log('Selected coach data:', selectedCoachData);
|
||||
console.log('Seats:', seats);
|
||||
console.log('Seats count:', seats.length);
|
||||
}, [selectedCoachData, seats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
||||
setSelectedCoach(filteredCoaches[0].id);
|
||||
@@ -143,15 +185,16 @@ export default function SeatsPage() {
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card mb-4">
|
||||
<h3 className="font-semibold mb-3">Select Coach</h3>
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select Coach</h3>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="mb-3 text-sm text-gray-600">
|
||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{filteredCoaches?.map((coach: any) => {
|
||||
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
||||
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
|
||||
return (
|
||||
<button
|
||||
key={coach.id}
|
||||
@@ -159,11 +202,11 @@ export default function SeatsPage() {
|
||||
className={`px-4 py-2 rounded whitespace-nowrap ${
|
||||
selectedCoach === coach.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300'
|
||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div>{coach.label || coach.coachNumber}</div>
|
||||
<div className="text-xs opacity-75">{coach.seatClass?.name || coach.coachClass}</div>
|
||||
<div>{coach.label || coach.name || coach.coachNumber}</div>
|
||||
<div className="text-xs opacity-75">{seatClassName}</div>
|
||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
||||
</button>
|
||||
);
|
||||
@@ -172,16 +215,25 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Seat Map - {selectedCoachData?.name || selectedCoachData?.label}</h3>
|
||||
{seats.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat Map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Loading seats...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
||||
<p>Error loading seats</p>
|
||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
||||
</div>
|
||||
) : seats.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No seats available in this coach</p>
|
||||
<p className="text-sm mt-2">Please select a different coach</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Seat Grid */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4 overflow-x-auto">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto">
|
||||
<div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}>
|
||||
{seats?.map((seat: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||
@@ -194,10 +246,10 @@ export default function SeatsPage() {
|
||||
selectedSeats.includes(seat.id)
|
||||
? 'bg-primary text-white shadow-md scale-105'
|
||||
: seat.status === 'AVAILABLE'
|
||||
? 'bg-green-100 hover:bg-green-200 text-green-800 hover:shadow-md'
|
||||
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md'
|
||||
: seat.status === 'HELD'
|
||||
? 'bg-yellow-100 text-yellow-700 cursor-not-allowed opacity-75'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed opacity-60'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
title={`Seat ${seatLabel} - ${seat.status}`}
|
||||
>
|
||||
@@ -209,9 +261,9 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-100 rounded"></div>
|
||||
<div className="w-6 h-6 bg-green-100 dark:bg-green-900/40 rounded"></div>
|
||||
<span>Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -219,11 +271,11 @@ export default function SeatsPage() {
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-yellow-100 rounded"></div>
|
||||
<div className="w-6 h-6 bg-yellow-100 dark:bg-yellow-900/40 rounded"></div>
|
||||
<span>Held</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-gray-200 rounded"></div>
|
||||
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<span>Booked</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,11 +286,11 @@ export default function SeatsPage() {
|
||||
|
||||
<div>
|
||||
<div className="card sticky top-4">
|
||||
<h3 className="font-semibold mb-4">Selection Summary</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection Summary</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Select {passengers.length} seat(s) for your passengers
|
||||
</p>
|
||||
<p className="text-lg font-semibold mb-4">
|
||||
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
{selectedSeats.length} / {passengers.length} selected
|
||||
</p>
|
||||
|
||||
@@ -247,7 +299,7 @@ export default function SeatsPage() {
|
||||
const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
||||
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
||||
return (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>{p.name}</span>
|
||||
<span className="font-medium">
|
||||
{seatLabel}
|
||||
|
||||
245
apps/edr-passenger-web/portal/src/app/guide/page.tsx
Normal file
245
apps/edr-passenger-web/portal/src/app/guide/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Search, Users, CreditCard, Ticket, CheckCircle, Train, Calendar, MapPin } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function HowToGuidePage() {
|
||||
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-4xl mx-auto">
|
||||
<Link
|
||||
href="/booking/search"
|
||||
className="inline-flex items-center gap-2 text-primary hover:underline mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Search
|
||||
</Link>
|
||||
|
||||
<div className="card mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">How to Book Your Train Ticket</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Follow these simple steps to book your journey
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Steps */}
|
||||
<div className="space-y-8">
|
||||
{/* Step 1 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<Search className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">1. Search for Trains</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Select your origin and destination stations, choose your travel date, and specify the number of passengers.
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-primary" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Select departure and arrival stations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Choose your travel date (today or future)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Specify adults and children (under 5 years)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">2. Select Your Train & Class</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Browse available trains, compare prices, and select your preferred seat class.
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Available Classes:</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li>• <strong>Economy Regular</strong> - Standard seating</li>
|
||||
<li>• <strong>Economy Bed</strong> - Sleeper berths</li>
|
||||
<li>• <strong>VIP Bed</strong> - Premium sleeper cabins</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter Passenger Details</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
You can sign in for a faster experience or continue as a guest.
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">For Ethiopian Citizens:</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">Click "Verify with Fayda" to auto-fill your details using your national ID.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">For International Travelers:</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">Enter your passport details and personal information manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<Ticket className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select Seats</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Choose your preferred seats from the interactive seat map. Available seats are shown in green.
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-500 rounded"></div>
|
||||
<span className="text-gray-700 dark:text-gray-300">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-yellow-500 rounded"></div>
|
||||
<span className="text-gray-700 dark:text-gray-300">Selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-gray-400 rounded"></div>
|
||||
<span className="text-gray-700 dark:text-gray-300">Booked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 5 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<CreditCard className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">5. Review & Pay</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Review your booking details and complete the payment.
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Payment Methods:</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li>• Telebirr</li>
|
||||
<li>• CBE Birr</li>
|
||||
<li>• Credit/Debit Card</li>
|
||||
<li>• E-Wallet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 6 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">6. Get Your Tickets</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Your tickets will be displayed with QR codes. Save or print them for boarding.
|
||||
</p>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4 rounded-lg">
|
||||
<p className="text-sm text-green-800 dark:text-green-300">
|
||||
📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQs */}
|
||||
<div className="card">
|
||||
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-gray-100">Frequently Asked Questions</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Can I book tickets without an account?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Yes! You can book as a guest. However, creating an account allows you to save passenger details and view booking history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What is Fayda verification?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fayda is the Ethiopian national ID verification system. It allows Ethiopian citizens to quickly verify their identity and auto-fill their information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">How does age-based pricing work?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Adults (5+ years) pay full fare. Children under 5 years travel free for the first child; additional children pay full fare.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Can I change or cancel my booking?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Yes, you can modify or cancel your booking through your account. Cancellation policies apply.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Which currencies are supported?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
All transactions are in ETB (Ethiopian Birr). You can view prices in DJF (Djiboutian Franc) or USD for reference.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What should I bring on the day of travel?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center mt-8">
|
||||
<Link href="/booking/search" className="btn-primary inline-flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
Start Booking Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EDR Passenger Portal - Book Your Train Journey',
|
||||
description: 'Book train tickets on the Ethio-Djibouti Railway',
|
||||
@@ -18,7 +15,7 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<body className="font-sans antialiased">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
|
||||
@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState } from 'react';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { Train } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -15,10 +16,11 @@ const loginSchema = z.object({
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const { searchCriteria } = useBookingStore();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,26 +43,26 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center py-12 px-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Train className="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Sign In</h1>
|
||||
<p className="text-gray-600 mt-2">Welcome back to EDR Platform</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign In</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back to EDR Platform</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
@@ -73,7 +75,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password')}
|
||||
@@ -92,8 +94,13 @@ export default function LoginPage() {
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/auth-check')}
|
||||
className="text-sm text-gray-600 hover:text-primary"
|
||||
onClick={() => {
|
||||
// If booking is started (search criteria exists), go back to passengers page
|
||||
// Otherwise, go to booking search page
|
||||
const destination = searchCriteria ? '/booking/passengers' : '/booking/search';
|
||||
router.push(destination);
|
||||
}}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
>
|
||||
← Back to booking
|
||||
</button>
|
||||
@@ -103,3 +110,18 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Train className="w-12 h-12 text-primary animate-pulse mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
818
apps/edr-passenger-web/portal/src/app/profile/page.tsx
Normal file
818
apps/edr-passenger-web/portal/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,818 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import {
|
||||
User, Settings, Ticket, Calendar, MapPin,
|
||||
Download, Trash2, Lock, Bell, CreditCard,
|
||||
MapPinned, Palette, CheckCircle,
|
||||
Eye, Edit, LogOut, X
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
|
||||
type Tab = 'bookings' | 'profile' | 'settings';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
pnr: string;
|
||||
status: string;
|
||||
totalMinor: number;
|
||||
createdAt: string;
|
||||
trip?: {
|
||||
trainNumber: string;
|
||||
departureAt: string;
|
||||
origin?: { name: string };
|
||||
destination?: { name: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('bookings');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalConfig, setModalConfig] = useState<any>({});
|
||||
const [showEditProfile, setShowEditProfile] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
fullName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
dateOfBirth: '',
|
||||
gender: '',
|
||||
nationality: '',
|
||||
});
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
preferredOrigin: '',
|
||||
preferredPaymentMethod: 'TELEBIRR',
|
||||
preferredCurrency: 'ETB',
|
||||
preferredLanguage: 'en',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (isAuthenticated && user) {
|
||||
// Fetch fresh profile data
|
||||
fetchProfile().catch(() => {
|
||||
// If fetch fails, redirect to login
|
||||
router.push('/login?redirect=/profile');
|
||||
});
|
||||
|
||||
setEditForm({
|
||||
fullName: user.fullName || '',
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
dateOfBirth: user.dateOfBirth || '',
|
||||
gender: user.gender || '',
|
||||
nationality: user.nationality || '',
|
||||
});
|
||||
} else if (!isAuthenticated) {
|
||||
router.push('/login?redirect=/profile');
|
||||
}
|
||||
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
|
||||
|
||||
const { data: bookings, isLoading: loadingBookings } = useQuery({
|
||||
queryKey: ['user-bookings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient.get('/bookings/my-bookings');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated && activeTab === 'bookings',
|
||||
});
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
|
||||
onSuccess: (response) => {
|
||||
const updatedData = (response as any).data || response;
|
||||
updateUser(updatedData);
|
||||
setShowEditProfile(false);
|
||||
setModalConfig({
|
||||
type: 'success',
|
||||
title: 'Profile Updated',
|
||||
message: 'Your profile has been updated successfully.',
|
||||
});
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setModalConfig({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: error.response?.data?.message || 'Failed to update profile. Please try again.',
|
||||
});
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
apiClient.patch('/auth/change-password', data),
|
||||
onSuccess: () => {
|
||||
setShowChangePassword(false);
|
||||
setModalConfig({
|
||||
type: 'success',
|
||||
title: 'Password Changed',
|
||||
message: 'Your password has been updated successfully.',
|
||||
});
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setModalConfig({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: error.response?.data?.message || 'Failed to change password. Please check your current password.',
|
||||
});
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const downloadDataMutation = useMutation({
|
||||
mutationFn: () => apiClient.get('/auth/download-data'),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `edr-data-${new Date().toISOString()}.json`;
|
||||
a.click();
|
||||
setModalConfig({
|
||||
type: 'success',
|
||||
title: 'Data Downloaded',
|
||||
message: 'Your data has been downloaded successfully.',
|
||||
});
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: () => apiClient.delete('/auth/account'),
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
router.push('/booking/search');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditProfile = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
updateProfileMutation.mutate(editForm);
|
||||
};
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const currentPassword = formData.get('currentPassword') as string;
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setModalConfig({
|
||||
type: 'error',
|
||||
title: 'Password Mismatch',
|
||||
message: 'New passwords do not match.',
|
||||
});
|
||||
setShowModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordMutation.mutate({ currentPassword, newPassword });
|
||||
e.currentTarget.reset();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setModalConfig({
|
||||
type: 'warning',
|
||||
title: 'Sign Out',
|
||||
message: 'Are you sure you want to sign out?',
|
||||
onConfirm: async () => {
|
||||
await logout();
|
||||
// Navigation will be handled by logout function
|
||||
},
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDownloadData = () => {
|
||||
setModalConfig({
|
||||
type: 'warning',
|
||||
title: 'Download Your Data',
|
||||
message: 'This will download all your personal data in JSON format. Continue?',
|
||||
onConfirm: () => {
|
||||
downloadDataMutation.mutate();
|
||||
setShowModal(false);
|
||||
},
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
setModalConfig({
|
||||
type: 'error',
|
||||
title: 'Delete Account',
|
||||
message: 'This action cannot be undone. All your data will be permanently deleted. Are you sure?',
|
||||
onConfirm: () => {
|
||||
deleteAccountMutation.mutate();
|
||||
setShowModal(false);
|
||||
},
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles = {
|
||||
CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||
PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
||||
CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||
COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
|
||||
};
|
||||
return styles[status as keyof typeof styles] || styles.PENDING;
|
||||
};
|
||||
|
||||
if (!isInitialized || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="card mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
|
||||
<User className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{user.fullName}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">{user.email}</p>
|
||||
{user.faydaVerified && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Verified with Fayda
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="card mb-6">
|
||||
<div className="flex gap-2 border-b dark:border-gray-700 pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('bookings')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'bookings'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Ticket className="w-4 h-4" />
|
||||
Bookings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'profile'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'settings'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'bookings' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
|
||||
|
||||
{loadingBookings ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
|
||||
</div>
|
||||
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
|
||||
bookings.map((booking: Booking) => (
|
||||
<div key={booking.id} className="card hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`badge ${getStatusBadge(booking.status)}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
PNR: {booking.pnr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{booking.trip?.departureAt
|
||||
? new Date(booking.trip.departureAt).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{booking.trip?.origin?.name} → {booking.trip?.destination?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-900 dark:text-gray-100 font-semibold">
|
||||
ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => router.push(`/booking/confirmation?id=${booking.id}`)}
|
||||
className="btn-secondary text-sm flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<Ticket className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">No bookings yet</p>
|
||||
<button onClick={() => router.push('/booking/search')} className="btn-primary">
|
||||
Book Your First Trip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Profile Information</h2>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.fullName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">
|
||||
{user.dateOfBirth ? new Date(user.dateOfBirth).toLocaleDateString() : 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.gender || 'Not set'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.nationality || 'Not set'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone</label>
|
||||
<div className="input-field bg-gray-50 dark:bg-gray-800">{user.phone || 'Not set'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowEditProfile(true)}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Settings</h2>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<Palette className="w-5 h-5" />
|
||||
Appearance
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Theme</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as any)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<Bell className="w-5 h-5" />
|
||||
Notifications
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between cursor-pointer group">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Push Notifications</span>
|
||||
<div className="relative inline-block w-12 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications}
|
||||
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer group">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Email Notifications</span>
|
||||
<div className="relative inline-block w-12 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailNotifications}
|
||||
onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer group">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Notifications</span>
|
||||
<div className="relative inline-block w-12 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smsNotifications}
|
||||
onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferences */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<MapPinned className="w-5 h-5" />
|
||||
Preferences
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Origin</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.preferredOrigin}
|
||||
onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="e.g., Addis Ababa"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Payment Method</label>
|
||||
<select
|
||||
value={settings.preferredPaymentMethod}
|
||||
onChange={(e) => setSettings({ ...settings, preferredPaymentMethod: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="TELEBIRR">Telebirr</option>
|
||||
<option value="CBE_BIRR">CBE Birr</option>
|
||||
<option value="CARD">Credit/Debit Card</option>
|
||||
<option value="WALLET">E-Wallet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Preferred Currency</label>
|
||||
<select
|
||||
value={settings.preferredCurrency}
|
||||
onChange={(e) => setSettings({ ...settings, preferredCurrency: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="ETB">ETB (Ethiopian Birr)</option>
|
||||
<option value="DJF">DJF (Djiboutian Franc)</option>
|
||||
<option value="USD">USD (US Dollar)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Language</label>
|
||||
<select
|
||||
value={settings.preferredLanguage}
|
||||
onChange={(e) => setSettings({ ...settings, preferredLanguage: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="am">አማርኛ (Amharic)</option>
|
||||
<option value="fr">Français (French)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<Lock className="w-5 h-5" />
|
||||
Security
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">Password</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Change your account password</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data & Privacy */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<Download className="w-5 h-5" />
|
||||
Data & Privacy
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">Download Your Data</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Get a copy of all your data</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownloadData}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
disabled={downloadDataMutation.isPending}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-red-600 dark:text-red-400">Delete Account</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Permanently delete your account and all data</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
{showEditProfile && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Edit Profile</h3>
|
||||
<button
|
||||
onClick={() => setShowEditProfile(false)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleEditProfile} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.fullName}
|
||||
onChange={(e) => setEditForm({ ...editForm, fullName: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.dateOfBirth}
|
||||
onChange={(e) => setEditForm({ ...editForm, dateOfBirth: e.target.value })}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Gender</label>
|
||||
<select
|
||||
value={editForm.gender}
|
||||
onChange={(e) => setEditForm({ ...editForm, gender: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||
<select
|
||||
value={editForm.nationality}
|
||||
onChange={(e) => setEditForm({ ...editForm, nationality: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select nationality</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={editForm.phone}
|
||||
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="+251911234567"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditProfile(false)}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
{showChangePassword && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Change Password</h3>
|
||||
<button
|
||||
onClick={() => setShowChangePassword(false)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
className="input-field"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
className="input-field"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangePassword(false)}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<CustomModal
|
||||
isOpen={showModal}
|
||||
type={modalConfig.type}
|
||||
title={modalConfig.title}
|
||||
message={modalConfig.message}
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={modalConfig.onConfirm}
|
||||
showCancel={modalConfig.type === 'warning' || modalConfig.type === 'error'}
|
||||
confirmText={modalConfig.type === 'warning' || modalConfig.type === 'error' ? 'Yes' : 'OK'}
|
||||
cancelText="Cancel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { Train } from 'lucide-react';
|
||||
import { Train, User, BookOpen, LogIn } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function AppHeader() {
|
||||
const { user, isAuthenticated, initialize } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -26,8 +33,35 @@ export default function AppHeader() {
|
||||
</Link>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isAuthenticated ? (
|
||||
<Link
|
||||
href="/login"
|
||||
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="Sign In"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Profile"
|
||||
>
|
||||
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||
{user?.fullName}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/guide"
|
||||
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="How to Book"
|
||||
>
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function DualCalendarPicker({
|
||||
}: DualCalendarPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||
const [currentDate, setCurrentDate] = useState(value || new Date());
|
||||
const [_currentDate, setCurrentDate] = useState(value || new Date());
|
||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
|
||||
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
|
||||
|
||||
|
||||
@@ -68,17 +68,17 @@ export default function ModernDatePicker({
|
||||
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
||||
|
||||
if (newType === 'ethiopian') {
|
||||
// Sync Ethiopian view to current Gregorian view
|
||||
const currentViewDate = new Date(viewYear, viewMonth, 15);
|
||||
const ethDate = gregorianToEthiopian(currentViewDate);
|
||||
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view
|
||||
// Use today's date if no value is selected, otherwise use the selected value
|
||||
const referenceDate = value || new Date();
|
||||
const ethDate = gregorianToEthiopian(referenceDate);
|
||||
setEthViewMonth(ethDate.month);
|
||||
setEthViewYear(ethDate.year);
|
||||
} else {
|
||||
// Sync Gregorian view to current Ethiopian view
|
||||
const currentEthDate = { year: ethViewYear, month: ethViewMonth, day: 15 };
|
||||
const gregDate = ethiopianToGregorian(currentEthDate);
|
||||
setViewMonth(gregDate.getMonth());
|
||||
setViewYear(gregDate.getFullYear());
|
||||
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view
|
||||
const referenceDate = value || new Date();
|
||||
setViewMonth(referenceDate.getMonth());
|
||||
setViewYear(referenceDate.getFullYear());
|
||||
}
|
||||
|
||||
setCalendarType(newType);
|
||||
|
||||
@@ -27,23 +27,21 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
||||
|
||||
return (
|
||||
<nav aria-label="Progress" className="py-6">
|
||||
<ol className="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<ol className="flex items-center max-w-4xl mx-auto">
|
||||
{steps.map((step, index) => {
|
||||
const isComplete = index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
|
||||
return (
|
||||
<li key={step.id} className="relative flex-1 flex flex-col items-center">
|
||||
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
|
||||
<div className="flex items-center w-full">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={`flex-1 h-1 transition-all duration-300 ${
|
||||
isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`relative flex h-10 w-10 items-center justify-center rounded-full transition-all duration-300 ${
|
||||
className={`flex-1 h-1 transition-all duration-300 ${
|
||||
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`relative flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
|
||||
isComplete
|
||||
? 'bg-primary shadow-lg scale-110'
|
||||
: isCurrent
|
||||
@@ -63,21 +61,23 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 transition-all duration-300 ${
|
||||
isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex-1 h-1 transition-all duration-300 ${
|
||||
index === steps.length - 1 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<div className={`flex-1 ${index === 0 ? 'opacity-0' : ''}`} />
|
||||
<p
|
||||
className={`mt-3 text-xs md:text-sm font-medium transition-colors flex-shrink-0 ${
|
||||
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
<div className={`flex-1 ${index === steps.length - 1 ? 'opacity-0' : ''}`} />
|
||||
</div>
|
||||
<p
|
||||
className={`mt-3 text-xs md:text-sm font-medium transition-colors ${
|
||||
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -37,18 +37,17 @@ export default function ThemeToggle() {
|
||||
// Prevent hydration mismatch by not rendering until mounted
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 w-[88px] h-[40px]" />
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-800" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={`Current theme: ${getLabel()}. Click to cycle.`}
|
||||
>
|
||||
{getIcon()}
|
||||
<span className="text-sm font-medium hidden sm:inline">{getLabel()}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Button, FormField } from "@edr/ui-common";
|
||||
|
||||
export interface ScheduleFormPayload {
|
||||
trainCode: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export interface ScheduleFormProps {
|
||||
onSubmit: (payload: ScheduleFormPayload) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const ScheduleForm = ({ onSubmit, isSubmitting }: ScheduleFormProps) => {
|
||||
const [trainCode, setTrainCode] = useState("");
|
||||
const [originStationId, setOriginStationId] = useState("");
|
||||
const [destinationStationId, setDestinationStationId] = useState("");
|
||||
const [departureTime, setDepartureTime] = useState("");
|
||||
const [arrivalTime, setArrivalTime] = useState("");
|
||||
const [basePrice, setBasePrice] = useState("0");
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
trainCode,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
departureTime,
|
||||
arrivalTime,
|
||||
basePrice: Number(basePrice),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField
|
||||
label="Train code"
|
||||
value={trainCode}
|
||||
onChange={(e) => setTrainCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Origin station ID"
|
||||
value={originStationId}
|
||||
onChange={(e) => setOriginStationId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Destination station ID"
|
||||
value={destinationStationId}
|
||||
onChange={(e) => setDestinationStationId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Departure"
|
||||
type="datetime-local"
|
||||
value={departureTime}
|
||||
onChange={(e) => setDepartureTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Arrival"
|
||||
type="datetime-local"
|
||||
value={arrivalTime}
|
||||
onChange={(e) => setArrivalTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Base price"
|
||||
type="number"
|
||||
value={basePrice}
|
||||
onChange={(e) => setBasePrice(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Publish schedule
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleForm;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Passenger } from "@edr/types";
|
||||
import { Table, type TableColumn } from "@edr/ui-common";
|
||||
|
||||
export interface ScheduleTableProps {
|
||||
schedules: Passenger.ISchedule[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Passenger.ISchedule>[] = [
|
||||
{ key: "trainCode", header: "Train" },
|
||||
{ key: "status", header: "Status" },
|
||||
{
|
||||
key: "departureTime",
|
||||
header: "Departure",
|
||||
render: (row) => new Date(row.departureTime).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "arrivalTime",
|
||||
header: "Arrival",
|
||||
render: (row) => new Date(row.arrivalTime).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "basePrice",
|
||||
header: "Base price",
|
||||
render: (row) => row.basePrice.toFixed(2),
|
||||
},
|
||||
];
|
||||
|
||||
const ScheduleTable = ({ schedules }: ScheduleTableProps) => (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={schedules}
|
||||
rowKey={(row) => row.id}
|
||||
emptyMessage="No schedules yet"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ScheduleTable;
|
||||
@@ -1,42 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import type { Passenger } from "@edr/types";
|
||||
|
||||
export interface SeatSelectorProps {
|
||||
seats: Passenger.ISeat[];
|
||||
selectedSeatId?: string;
|
||||
onSelect: (seatId: string) => void;
|
||||
}
|
||||
|
||||
const SeatSelector = ({
|
||||
seats,
|
||||
selectedSeatId,
|
||||
onSelect,
|
||||
}: SeatSelectorProps) => (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{seats.map((seat) => {
|
||||
const isAvailable = seat.status === "AVAILABLE";
|
||||
const isSelected = seat.id === selectedSeatId;
|
||||
return (
|
||||
<button
|
||||
key={seat.id}
|
||||
type="button"
|
||||
disabled={!isAvailable}
|
||||
onClick={() => onSelect(seat.id)}
|
||||
className={clsx(
|
||||
"w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all",
|
||||
isSelected
|
||||
? "bg-primary text-white shadow-md scale-105"
|
||||
: isAvailable
|
||||
? "bg-green-100 hover:bg-green-200 text-green-800 hover:shadow-md"
|
||||
: "cursor-not-allowed bg-gray-200 text-gray-500 opacity-60",
|
||||
)}
|
||||
title={`Seat ${seat.seatNumber} - ${seat.status}`}
|
||||
>
|
||||
{seat.seatNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SeatSelector;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Button, FormField } from "@edr/ui-common";
|
||||
|
||||
import type { CreateTicketPayload } from "../../services/tickets.service";
|
||||
|
||||
export interface TicketFormProps {
|
||||
onSubmit: (payload: CreateTicketPayload) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const TicketForm = ({ onSubmit, isSubmitting }: TicketFormProps) => {
|
||||
const [reference, setReference] = useState("");
|
||||
const [passengerId, setPassengerId] = useState("");
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [seatId, setSeatId] = useState("");
|
||||
const [pricePaid, setPricePaid] = useState("0");
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
reference,
|
||||
passengerId,
|
||||
scheduleId,
|
||||
seatId,
|
||||
pricePaid: Number(pricePaid),
|
||||
issuedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField
|
||||
label="Reference"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Passenger ID"
|
||||
value={passengerId}
|
||||
onChange={(e) => setPassengerId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Schedule ID"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Seat ID"
|
||||
value={seatId}
|
||||
onChange={(e) => setSeatId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Price paid"
|
||||
type="number"
|
||||
value={pricePaid}
|
||||
onChange={(e) => setPricePaid(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Issue ticket
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketForm;
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Passenger } from "@edr/types";
|
||||
import { Table, type TableColumn } from "@edr/ui-common";
|
||||
|
||||
export interface TicketTableProps {
|
||||
tickets: Passenger.ITicket[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Passenger.ITicket>[] = [
|
||||
{ key: "reference", header: "Reference" },
|
||||
{ key: "passengerId", header: "Passenger" },
|
||||
{ key: "status", header: "Status" },
|
||||
{
|
||||
key: "issuedAt",
|
||||
header: "Issued",
|
||||
render: (row) => new Date(row.issuedAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "pricePaid",
|
||||
header: "Price",
|
||||
render: (row) => row.pricePaid.toFixed(2),
|
||||
},
|
||||
];
|
||||
|
||||
const TicketTable = ({ tickets }: TicketTableProps) => (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={tickets}
|
||||
rowKey={(row) => row.id}
|
||||
emptyMessage="No tickets yet"
|
||||
/>
|
||||
);
|
||||
|
||||
export default TicketTable;
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { schedulesService } from "../services/schedules.service";
|
||||
|
||||
export const useSchedules = () =>
|
||||
useQuery({
|
||||
queryKey: ["schedules"],
|
||||
queryFn: schedulesService.list,
|
||||
});
|
||||
|
||||
export const useSchedule = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["schedules", id],
|
||||
queryFn: () => schedulesService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { stationsService } from "../services/stations.service";
|
||||
|
||||
export const useStations = () =>
|
||||
useQuery({
|
||||
queryKey: ["stations"],
|
||||
queryFn: stationsService.list,
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ticketsService } from "../services/tickets.service";
|
||||
|
||||
export const useTickets = () =>
|
||||
useQuery({
|
||||
queryKey: ["tickets"],
|
||||
queryFn: ticketsService.list,
|
||||
});
|
||||
|
||||
export const useTicket = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["tickets", id],
|
||||
queryFn: () => ticketsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -25,7 +25,11 @@ class ApiClient {
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Don't redirect if it's a login or register request (invalid credentials)
|
||||
const isAuthEndpoint = error.config?.url?.includes('/auth/login') ||
|
||||
error.config?.url?.includes('/auth/register');
|
||||
|
||||
if (!isAuthEndpoint && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -7,17 +7,25 @@ interface User {
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
faydaVerified?: boolean;
|
||||
faydaSub?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isInitialized: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setUser: (user: User, token: string) => void;
|
||||
initialize: () => void;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
initialize: () => Promise<void>;
|
||||
fetchProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
@@ -27,23 +35,62 @@ interface RegisterData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
|
||||
initialize: () => {
|
||||
initialize: async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const userStr = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
set({ user, token, isAuthenticated: true });
|
||||
set({ user, token, isAuthenticated: true, isInitialized: true });
|
||||
|
||||
// Fetch fresh profile data in background
|
||||
get().fetchProfile().catch(() => {
|
||||
// If profile fetch fails, token might be expired
|
||||
console.warn('Failed to fetch profile, token might be expired');
|
||||
});
|
||||
} catch (e) {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
set({ isInitialized: true });
|
||||
}
|
||||
} else {
|
||||
set({ isInitialized: true });
|
||||
}
|
||||
},
|
||||
|
||||
fetchProfile: async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.get('/auth/profile', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const userData = response.data || response;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(userData));
|
||||
}
|
||||
set({ user: userData });
|
||||
} catch (error: any) {
|
||||
// If 401, token is invalid - logout
|
||||
if (error.response?.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,8 +98,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
const response: any = await apiClient.post('/auth/login', { email, password });
|
||||
const { token, user } = response.data || response;
|
||||
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
@@ -61,22 +110,60 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
const response: any = await apiClient.post('/auth/register', data);
|
||||
const { token, user } = response.data || response;
|
||||
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
if (token) {
|
||||
// Call logout endpoint to invalidate session on backend
|
||||
await apiClient.post('/auth/logout', {}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
// Continue with logout even if API call fails
|
||||
}
|
||||
|
||||
// Clear local storage and state
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
|
||||
// Redirect to home page after state is updated
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user: User, token: string) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
|
||||
updateUser: (userData: Partial<User>) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const currentUser = JSON.parse(localStorage.getItem('auth_user') || '{}');
|
||||
const updatedUser = { ...currentUser, ...userData };
|
||||
localStorage.setItem('auth_user', JSON.stringify(updatedUser));
|
||||
set({ user: updatedUser });
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -19,9 +19,15 @@ export interface PassengerDetail {
|
||||
faydaSub?: string;
|
||||
passportNumber?: string;
|
||||
passportCountry?: string;
|
||||
passportIssueDate?: string;
|
||||
passportExpiryDate?: string;
|
||||
passportIssuingAuthority?: string;
|
||||
idDocumentType?: string;
|
||||
isPrimaryPassenger: boolean;
|
||||
seatId?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
export interface SelectedSchedule {
|
||||
@@ -96,6 +102,15 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
}),
|
||||
{
|
||||
name: 'booking-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
storage: createJSONStorage(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage;
|
||||
}
|
||||
return {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
}),
|
||||
}
|
||||
));
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ethiopianToGregorian(ethDate: EthiopianDate): Date {
|
||||
/**
|
||||
* Get day of year from date (1-366)
|
||||
*/
|
||||
function getDayOfYear(date: Date): number {
|
||||
export function getDayOfYear(date: Date): number {
|
||||
const start = new Date(date.getFullYear(), 0, 0);
|
||||
const diff = date.getTime() - start.getTime();
|
||||
const oneDay = 1000 * 60 * 60 * 24;
|
||||
@@ -109,7 +109,7 @@ function getDayOfYear(date: Date): number {
|
||||
/**
|
||||
* Convert day of year to Date object
|
||||
*/
|
||||
function dayOfYearToDate(year: number, dayOfYear: number): Date {
|
||||
export function dayOfYearToDate(year: number, dayOfYear: number): Date {
|
||||
const date = new Date(year, 0);
|
||||
date.setDate(dayOfYear);
|
||||
return date;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
const DashboardPage = () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Passenger Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Daily ridership, ticket sales, occupancy by class, and schedule status go
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
const PassengersPage = () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Passengers</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Passenger directory. Connect to <code>/passengers</code> when ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PassengersPage;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { useSchedule } from "../../hooks/useSchedules";
|
||||
|
||||
const ScheduleDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data, isLoading } = useSchedule(id ?? "");
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||
if (!data)
|
||||
return <div className="text-sm text-red-600">Schedule not found.</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Schedule {data.trainCode}
|
||||
</h1>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-gray-500">Status</dt>
|
||||
<dd className="text-gray-900">{data.status}</dd>
|
||||
<dt className="text-gray-500">Departure</dt>
|
||||
<dd className="text-gray-900">
|
||||
{new Date(data.departureTime).toLocaleString()}
|
||||
</dd>
|
||||
<dt className="text-gray-500">Arrival</dt>
|
||||
<dd className="text-gray-900">
|
||||
{new Date(data.arrivalTime).toLocaleString()}
|
||||
</dd>
|
||||
<dt className="text-gray-500">Base price</dt>
|
||||
<dd className="text-gray-900">{data.basePrice.toFixed(2)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleDetailPage;
|
||||
@@ -1,20 +0,0 @@
|
||||
import ScheduleTable from "../../components/schedules/ScheduleTable";
|
||||
import { useSchedules } from "../../hooks/useSchedules";
|
||||
|
||||
const SchedulesPage = () => {
|
||||
const { data, isLoading } = useSchedules();
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Schedules</h1>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading…</div>
|
||||
) : (
|
||||
<ScheduleTable schedules={items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulesPage;
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Table, type TableColumn } from "@edr/ui-common";
|
||||
import type { Passenger } from "@edr/types";
|
||||
|
||||
import { useStations } from "../../hooks/useStations";
|
||||
|
||||
const columns: TableColumn<Passenger.IStation>[] = [
|
||||
{ key: "code", header: "Code" },
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "city", header: "City" },
|
||||
{ key: "country", header: "Country" },
|
||||
];
|
||||
|
||||
const StationsPage = () => {
|
||||
const { data, isLoading } = useStations();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Stations</h1>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading…</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data ?? []}
|
||||
rowKey={(row) => row.id}
|
||||
emptyMessage="No stations"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsPage;
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import TicketForm from "../../components/tickets/TicketForm";
|
||||
import { ticketsService } from "../../services/tickets.service";
|
||||
|
||||
const BookTicketPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ticketsService.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
||||
navigate("/tickets");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-4 text-2xl font-semibold text-gray-900">
|
||||
Issue ticket
|
||||
</h1>
|
||||
<TicketForm
|
||||
onSubmit={mutation.mutate}
|
||||
isSubmitting={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookTicketPage;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { useTicket } from "../../hooks/useTickets";
|
||||
|
||||
const TicketDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data, isLoading } = useTicket(id ?? "");
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||
if (!data)
|
||||
return <div className="text-sm text-red-600">Ticket not found.</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Ticket {data.reference}
|
||||
</h1>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-gray-500">Passenger ID</dt>
|
||||
<dd className="text-gray-900">{data.passengerId}</dd>
|
||||
<dt className="text-gray-500">Schedule ID</dt>
|
||||
<dd className="text-gray-900">{data.scheduleId}</dd>
|
||||
<dt className="text-gray-500">Seat ID</dt>
|
||||
<dd className="text-gray-900">{data.seatId}</dd>
|
||||
<dt className="text-gray-500">Status</dt>
|
||||
<dd className="text-gray-900">{data.status}</dd>
|
||||
<dt className="text-gray-500">Issued</dt>
|
||||
<dd className="text-gray-900">
|
||||
{new Date(data.issuedAt).toLocaleString()}
|
||||
</dd>
|
||||
<dt className="text-gray-500">Price paid</dt>
|
||||
<dd className="text-gray-900">{data.pricePaid.toFixed(2)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketDetailPage;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
import TicketTable from "../../components/tickets/TicketTable";
|
||||
import { useTickets } from "../../hooks/useTickets";
|
||||
|
||||
const TicketsPage = () => {
|
||||
const { data, isLoading } = useTickets();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Tickets</h1>
|
||||
<Link to="/tickets/new">
|
||||
<Button>Issue ticket</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading…</div>
|
||||
) : (
|
||||
<TicketTable tickets={items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsPage;
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Passenger } from "@edr/types";
|
||||
|
||||
import { api } from "../utils/api";
|
||||
|
||||
export const schedulesService = {
|
||||
list: async (): Promise<Passenger.ISchedule[]> => {
|
||||
const { data } = await api.get("/schedules");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Passenger.ISchedule> => {
|
||||
const { data } = await api.get(`/schedules/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { IStation } from "../types";
|
||||
import { api } from "../utils/api";
|
||||
|
||||
export const stationsService = {
|
||||
list: async (): Promise<IStation[]> => {
|
||||
const { data } = await api.get("/stations");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<IStation> => {
|
||||
const { data } = await api.get(`/stations/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Passenger, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api } from "../utils/api";
|
||||
|
||||
export interface CreateTicketPayload {
|
||||
reference: string;
|
||||
passengerId: string;
|
||||
scheduleId: string;
|
||||
seatId: string;
|
||||
pricePaid: number;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
export const ticketsService = {
|
||||
list: async (): Promise<PaginatedResponse<Passenger.ITicket>> => {
|
||||
const { data } = await api.get("/tickets");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Passenger.ITicket> => {
|
||||
const { data } = await api.get(`/tickets/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateTicketPayload): Promise<Passenger.ITicket> => {
|
||||
const { data } = await api.post("/tickets", payload);
|
||||
return data.data;
|
||||
},
|
||||
cancel: async (id: string): Promise<void> => {
|
||||
await api.delete(`/tickets/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
|
||||
// TODO: integrate @edr/auth — add a request interceptor here that attaches
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -24,7 +24,18 @@ export default {
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
],
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
// test: {
|
||||
// environment: "jsdom",
|
||||
// globals: true,
|
||||
// },
|
||||
});
|
||||
63
checkpoint.md
Normal file
63
checkpoint.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Checkpoint
|
||||
|
||||
## Major Tasks Completed
|
||||
|
||||
1. Docker deployment scaffolded for all 6 apps in the monorepo.
|
||||
2. Split API images into app-specific Dockerfiles:
|
||||
- `apps/edr-freight-api/Dockerfile`
|
||||
- `apps/edr-passenger-api/Dockerfile`
|
||||
3. Kept shared Vite/nginx image:
|
||||
- `infrastructure/docker/Dockerfile.web`
|
||||
- `infrastructure/nginx/spa.conf`
|
||||
4. Updated `docker-compose.yaml` to run all 6 services (apps only, no Postgres service in compose).
|
||||
5. Added/updated deployment scripts:
|
||||
- `scripts/deploy/create-npmrc.sh`
|
||||
- `scripts/deploy/sync-env-from-server.sh`
|
||||
6. Added self-hosted GitHub Actions deployment workflow:
|
||||
- Consolidated into one file: `.github/workflows/deploy.yml`
|
||||
7. Deployment workflow now:
|
||||
- uses a single checkout (`prepare` job),
|
||||
- deploys services via parallel matrix,
|
||||
- sets compose project names per branch/environment,
|
||||
- passes explicit `docker compose --project-name`.
|
||||
8. Environment sync script now:
|
||||
- supports branch slug paths,
|
||||
- validates each service env file exists,
|
||||
- requires `PORT` in each env file,
|
||||
- exports per-service port vars to `GITHUB_ENV`.
|
||||
9. `docker-compose.yaml` now reads per-service ports via variables exported from env sync.
|
||||
10. Passenger startup flow fixed to run:
|
||||
- `prisma:generate`,
|
||||
- `prisma:migrate`,
|
||||
- `prisma:seed`,
|
||||
before API startup.
|
||||
11. Passenger seed TypeScript issues fixed in `apps/edr-passenger-api/prisma/seed.ts` so it compiles under strict checks.
|
||||
12. Added deployment runbook:
|
||||
- `DEPLOYMENT.md`
|
||||
|
||||
## Key Files to Review
|
||||
|
||||
- `.github/workflows/deploy.yml`
|
||||
- `docker-compose.yaml`
|
||||
- `scripts/deploy/sync-env-from-server.sh`
|
||||
- `scripts/deploy/create-npmrc.sh`
|
||||
- `apps/edr-passenger-api/docker-entrypoint.sh`
|
||||
- `apps/edr-passenger-api/prisma/seed.ts`
|
||||
- `DEPLOYMENT.md`
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. Run full CI on all target branches (`main`, `dev`, `staging`) and verify matrix job behavior.
|
||||
2. Validate server env directory layout matches script expectations:
|
||||
- `/home/<deploy_user>/environment/edr/<branch-slug>/<project>/...`
|
||||
3. Confirm each service env file includes valid `PORT` and service-specific runtime vars.
|
||||
4. Verify branch-specific compose project names produce isolated containers/networks/volumes on runner.
|
||||
5. Smoke test all 6 deployed services behind real environment URLs.
|
||||
|
||||
## Open Risks / Notes
|
||||
|
||||
1. Passenger seed runs on every container start; confirm this is desired for production-like environments.
|
||||
2. Prisma warns about `package.json#prisma` deprecation (Prisma 7 migration pending).
|
||||
3. Matrix parallelism increases runner load; ensure self-hosted runner capacity is sufficient.
|
||||
4. Port collisions are prevented by env-driven mapping, but bad env values can still cause runtime conflicts.
|
||||
|
||||
@@ -1,21 +1,84 @@
|
||||
# EDR Platform — application containers only (no Postgres).
|
||||
# Requires a local .npmrc with GitHub Packages auth for @tria-plc (freight API + freight web).
|
||||
# Copy apps/*/env.example to .env and set real values before `docker compose up`.
|
||||
#
|
||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||
# Run: docker compose up -d
|
||||
|
||||
services:
|
||||
freight-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: freight-api
|
||||
env_file:
|
||||
- .env
|
||||
dockerfile: apps/edr-freight-api/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- ${PORT}:${PORT}
|
||||
restart: unless-stopped
|
||||
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
|
||||
env_file:
|
||||
- apps/edr-freight-api/.env
|
||||
|
||||
passenger-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: passenger-api
|
||||
env_file:
|
||||
- .env
|
||||
dockerfile: apps/edr-passenger-api/Dockerfile
|
||||
ports:
|
||||
- ${PORT}:${PORT}
|
||||
restart: unless-stopped
|
||||
- "${PASSENGER_API_PORT:-4000}:${PASSENGER_API_PORT:-4000}"
|
||||
env_file:
|
||||
- apps/edr-passenger-api/.env
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.web
|
||||
args:
|
||||
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}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.web
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-backoffice"
|
||||
APP_PATH: apps/edr-freight-web/backoffice
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.web
|
||||
args:
|
||||
TURBO_FILTER: "@edr/passenger-portal"
|
||||
APP_PATH: apps/edr-passenger-web/portal
|
||||
NEXT_PUBLIC_API_URL: ${PASSENGER_API_URL:-http://localhost:4000}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_PORTAL_PORT:-5174}:80"
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.web
|
||||
args:
|
||||
TURBO_FILTER: "@edr/passenger-backoffice"
|
||||
APP_PATH: apps/edr-passenger-web/backoffice
|
||||
NEXT_PUBLIC_API_URL: ${PASSENGER_API_URL:-http://localhost:4000}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_BACKOFFICE_PORT:-5184}:80"
|
||||
|
||||
secrets:
|
||||
npmrc:
|
||||
file: .npmrc
|
||||
|
||||
88
infrastructure/docker/Dockerfile.web
Normal file
88
infrastructure/docker/Dockerfile.web
Normal file
@@ -0,0 +1,88 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG TURBO_FILTER=@edr/freight-portal
|
||||
ARG APP_PATH=apps/edr-freight-web/portal
|
||||
ARG VITE_API_URL=http://localhost:3001/api
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
ARG TURBO_FILTER
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "${TURBO_FILTER}" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
ARG TURBO_FILTER
|
||||
ARG APP_PATH
|
||||
ARG VITE_API_URL
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Configure npm/pnpm with extended timeouts and retries for network requests
|
||||
RUN npm config set fetch-timeout 120000 && \
|
||||
npm config set fetch-retry-mintimeout 20000 && \
|
||||
npm config set fetch-retry-maxtimeout 120000 && \
|
||||
npm config set fetch-retries 5
|
||||
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm turbo build --filter="${TURBO_FILTER}..." || \
|
||||
(echo "Build failed for ${TURBO_FILTER}" && \
|
||||
ls -la /app/${APP_PATH}/ 2>/dev/null || echo "App path does not exist" && \
|
||||
exit 1)
|
||||
|
||||
# Verify build output exists before proceeding
|
||||
RUN if [ ! -d "/app/${APP_PATH}/dist" ] && [ ! -d "/app/${APP_PATH}/out" ]; then \
|
||||
echo "ERROR: Build output directory not found at /app/${APP_PATH}/dist or /app/${APP_PATH}/out"; \
|
||||
echo "Contents of /app/${APP_PATH}:"; \
|
||||
ls -la /app/${APP_PATH}/ 2>/dev/null || echo "Directory does not exist"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
ARG APP_PATH
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY infrastructure/nginx/spa.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Remove default nginx files
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy build output - check for Next.js 'out' or Vite 'dist' directory
|
||||
# Use shell to handle conditional copy
|
||||
RUN --mount=type=bind,from=builder,source=/app,target=/build \
|
||||
if [ -d "/build/${APP_PATH}/out" ]; then \
|
||||
cp -r /build/${APP_PATH}/out/* /usr/share/nginx/html/; \
|
||||
echo "Copied from Next.js 'out' directory"; \
|
||||
elif [ -d "/build/${APP_PATH}/dist" ]; then \
|
||||
cp -r /build/${APP_PATH}/dist/* /usr/share/nginx/html/; \
|
||||
echo "Copied from Vite 'dist' directory"; \
|
||||
else \
|
||||
echo "Error: No build output found in 'out' or 'dist' directory"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Copy public directory if it exists (for static assets)
|
||||
RUN --mount=type=bind,from=builder,source=/app,target=/build \
|
||||
if [ -d "/build/${APP_PATH}/public" ]; then \
|
||||
mkdir -p /usr/share/nginx/html/public && \
|
||||
cp -r /build/${APP_PATH}/public/* /usr/share/nginx/html/public/ && \
|
||||
echo "Copied public directory"; \
|
||||
else \
|
||||
echo "No public directory found, skipping"; \
|
||||
fi
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,126 +0,0 @@
|
||||
version: "3.9"
|
||||
|
||||
networks:
|
||||
edr-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-freight-data:
|
||||
postgres-passenger-data:
|
||||
redis-data:
|
||||
|
||||
services:
|
||||
postgres-freight:
|
||||
image: postgres:17-alpine
|
||||
container_name: edr-postgres-freight
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: edr_freight
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-freight-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
postgres-passenger:
|
||||
image: postgres:17-alpine
|
||||
container_name: edr-postgres-passenger
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: edr_passenger
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- postgres-passenger-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: edr-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-freight-api:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-freight-api/Dockerfile
|
||||
container_name: edr-freight-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres-freight
|
||||
- redis
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: 3001
|
||||
DB_HOST: postgres-freight
|
||||
DB_PORT: 5432
|
||||
DB_NAME: edr_freight
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-freight-web:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-freight-web/Dockerfile
|
||||
container_name: edr-freight-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- edr-freight-api
|
||||
ports:
|
||||
- "5173:5173"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-passenger-api:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-passenger-api/Dockerfile
|
||||
container_name: edr-passenger-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres-passenger
|
||||
- redis
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: 3002
|
||||
DB_HOST: postgres-passenger
|
||||
DB_PORT: 5432
|
||||
DB_NAME: edr_passenger
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
ports:
|
||||
- "3002:3002"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-passenger-web:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-passenger-web/Dockerfile
|
||||
container_name: edr-passenger-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- edr-passenger-api
|
||||
ports:
|
||||
- "5174:5174"
|
||||
networks:
|
||||
- edr-network
|
||||
@@ -1,134 +0,0 @@
|
||||
version: "3.9"
|
||||
|
||||
networks:
|
||||
edr-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-freight-data:
|
||||
postgres-passenger-data:
|
||||
redis-data:
|
||||
|
||||
services:
|
||||
postgres-freight:
|
||||
image: postgres:17-alpine
|
||||
container_name: edr-postgres-freight
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME_FREIGHT}
|
||||
volumes:
|
||||
- postgres-freight-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
postgres-passenger:
|
||||
image: postgres:17-alpine
|
||||
container_name: edr-postgres-passenger
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME_PASSENGER}
|
||||
volumes:
|
||||
- postgres-passenger-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: edr-redis
|
||||
restart: always
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-freight-api:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-freight-api/Dockerfile
|
||||
container_name: edr-freight-api
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres-freight
|
||||
- redis
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DB_HOST: postgres-freight
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${DB_NAME_FREIGHT}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
expose:
|
||||
- "3001"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-freight-web:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-freight-web/Dockerfile
|
||||
container_name: edr-freight-web
|
||||
restart: always
|
||||
depends_on:
|
||||
- edr-freight-api
|
||||
expose:
|
||||
- "5173"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-passenger-api:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-passenger-api/Dockerfile
|
||||
container_name: edr-passenger-api
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres-passenger
|
||||
- redis
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3002
|
||||
DB_HOST: postgres-passenger
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${DB_NAME_PASSENGER}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
expose:
|
||||
- "3002"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
edr-passenger-web:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/edr-passenger-web/Dockerfile
|
||||
container_name: edr-passenger-web
|
||||
restart: always
|
||||
depends_on:
|
||||
- edr-passenger-api
|
||||
expose:
|
||||
- "5174"
|
||||
networks:
|
||||
- edr-network
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: edr-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- edr-freight-web
|
||||
- edr-freight-api
|
||||
- edr-passenger-web
|
||||
- edr-passenger-api
|
||||
volumes:
|
||||
- ../../infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- edr-network
|
||||
@@ -1,63 +0,0 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
server_tokens off;
|
||||
|
||||
upstream edr_freight_api {
|
||||
server edr-freight-api:3001;
|
||||
}
|
||||
upstream edr_freight_web {
|
||||
server edr-freight-web:5173;
|
||||
}
|
||||
upstream edr_passenger_api {
|
||||
server edr-passenger-api:4000;
|
||||
}
|
||||
upstream edr_passenger_web {
|
||||
server edr-passenger-web:5174;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name freight.edr.local;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://edr_freight_api/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://edr_freight_web/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name passenger.edr.local;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://edr_passenger_api/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://edr_passenger_web/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
infrastructure/nginx/spa.conf
Normal file
13
infrastructure/nginx/spa.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
"docker:build": "docker compose build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
packages:
|
||||
- "apps/edr-passenger-api"
|
||||
- "apps/edr-freight-api"
|
||||
- "apps/edr-passenger-web/*"
|
||||
- "packages/*"
|
||||
- "packages/config/*"
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
'@scarf/scarf': true
|
||||
"@nestjs/core": true
|
||||
"@prisma/client": true
|
||||
"@prisma/engines": true
|
||||
"@scarf/scarf": true
|
||||
argon2: true
|
||||
bcrypt: true
|
||||
core-js: true
|
||||
|
||||
19
scripts/deploy/create-npmrc.sh
Normal file
19
scripts/deploy/create-npmrc.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create .npmrc_temp and .npmrc for Docker BuildKit / compose secrets.
|
||||
# Requires NPM_TOKEN in the environment.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${NPM_TOKEN:-}" ]]; then
|
||||
echo "NPM_TOKEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat <<EOF > .npmrc_temp
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
EOF
|
||||
|
||||
cp .npmrc_temp .npmrc
|
||||
echo "Created .npmrc_temp and .npmrc for private @tria-plc packages"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user