Merge branch 'alpha' into passenger/feat/iam-integration

This commit is contained in:
Abubeker Yasin
2026-06-03 10:20:25 +03:00
102 changed files with 4839 additions and 1993 deletions

View File

@@ -1,7 +1,14 @@
**/node_modules
**/dist
**/.turbo
**/.git
**/.github
**/.vscode
**/.git
**/.idea
**/.env
.env
**/.env.*
!**/.env.example
**/coverage
**/*.tsbuildinfo
**/*.log
.DS_Store

View File

@@ -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
View 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
View 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
View 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

View File

@@ -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
View File

@@ -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.

View File

@@ -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

View 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"]

View File

@@ -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=

View 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"]

View 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 "$@"

View File

@@ -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",

View File

@@ -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

View File

@@ -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",
},
});

View File

@@ -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);
}
}

View File

@@ -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'
};
}
}

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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',
},

View File

@@ -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')

View File

@@ -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;
}

View File

@@ -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',
};
}
}

View File

@@ -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 } });

View File

@@ -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.';

View File

@@ -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;

View File

@@ -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,
},

View File

@@ -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,
},

View File

@@ -193,7 +193,7 @@ export default function CoachesPage() {
columns={columns}
data={coaches}
actions={actions}
isLoading={isLoading}
loading={isLoading}
/>
</div>

View File

@@ -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&apos;s what&apos;s happening today.</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -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>

View File

@@ -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"

View File

@@ -65,7 +65,7 @@ export default function PassengersPage() {
},
];
const actions = [
const actions: any[] = [
// TODO: Create passenger detail page
// {
// label: 'View Details',

View File

@@ -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"
/>

View File

@@ -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([]);
}}
>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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))',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>;
}

View File

@@ -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&apos;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>

View File

@@ -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>

View File

@@ -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>;
}

View File

@@ -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">&lt;5 years First child free</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First child free</div>
</div>
<div className="flex items-center gap-3">
<button

View File

@@ -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}

View 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 &quot;Verify with Fayda&quot; 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>
);
}

View File

@@ -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: `

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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());

View File

@@ -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);

View File

@@ -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>
);
})}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),
});

View File

@@ -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,
});

View File

@@ -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),
});

View File

@@ -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';

View File

@@ -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 });
}
},
})
);

View File

@@ -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: () => {},
};
}),
}
));

View File

@@ -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;

View File

@@ -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>,
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
},
};

View File

@@ -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;
},
};

View File

@@ -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}`);
},
};

View File

@@ -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

View File

@@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -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)',

View File

@@ -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
View 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.

View File

@@ -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

View 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;"]

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}
}

View 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;
}
}

View File

@@ -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": {

View File

@@ -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

View 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