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

This commit is contained in:
Marshal
2026-07-09 21:14:09 +00:00
115 changed files with 11050 additions and 1459 deletions

View File

@@ -0,0 +1,42 @@
---
name: edr-db
description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema.
---
# EDR dev-DB runner
One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`):
```bash
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first)
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
```
Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`,
defaulting to the shared dev database (`edr_dev`).
## Rules that go with it
- **HARD RULE: every raw SQL statement you write into a service must pass
`explain` here before you ship it.** A typo'd column is a runtime 500 the
type-checker cannot catch.
- Never assume a recorded migration applied — check `migrations <name>` **and**
`columns <table>` together. Recorded-but-absent = schema drift; fix with a
NEW repair migration (idempotent DDL, no-op `down()`), never by editing the
recorded one.
- Writes to dev data are fine for seeding/diagnosis but keep them idempotent
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
and non-idempotent statements have double-run here before.
- Timestamps for new migrations: must be unique across `src/migrations/` AND
higher than `SELECT max(timestamp) FROM public.migrations`.
## Diagnosing a pasted 400/500 (the recurring loop)
1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`.
2. Read the service method — list its guard `throw`s. Most "bugs" are a guard
working as designed (handover unsigned, fee unpaid, not PAID, wrong direction).
3. Check the actual DB state for that record with this runner.
4. Only then decide: guard doing its job (fix the UI affordance) vs real defect.

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Dev-DB runner for the EDR freight database. psql is NOT installed on this
* machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect
* the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from
* anywhere in the repo.
*
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
* node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
*
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
* back to the shared dev database.
*/
const path = require('path');
const { createRequire } = require('module');
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const apiRequire = createRequire(
path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'),
);
const { Client } = apiRequire('pg');
const cfg = {
host: process.env.DB_HOST ?? '10.18.7.207',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? 'dcba@1234',
database: process.env.DB_NAME ?? 'edr_dev',
};
const [, , first, ...rest] = process.argv;
async function main() {
if (!first) {
console.error('usage: query.cjs "<sql>" | explain "<sql>" | columns <table> | migrations [like] | drift <table>');
process.exit(2);
}
const c = new Client(cfg);
await c.connect();
try {
if (first === 'columns') {
const r = await c.query(
`SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='freight' AND table_name=$1
ORDER BY ordinal_position`,
[rest[0]],
);
console.table(r.rows);
} else if (first === 'migrations') {
const like = rest[0] ? `%${rest[0]}%` : '%';
const r = await c.query(
`SELECT id, timestamp, name FROM public.migrations
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
[like],
);
console.table(r.rows);
} else if (first === 'drift') {
// Quick drift signal: DB columns for the table. Compare by eye against
// the entity's @Column names; a recorded-but-absent column = drift.
const r = await c.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`,
[rest[0]],
);
console.log(r.rows.map((x) => x.column_name).join('\n'));
} else if (first === 'explain') {
await c.query('EXPLAIN ' + rest.join(' '));
console.log('OK — statement is valid against', cfg.database);
} else {
const sql = [first, ...rest].join(' ');
const started = Date.now();
const r = await c.query(sql);
if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows);
console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`);
}
} finally {
await c.end();
}
}
main().catch((e) => {
console.error('FAIL:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
---
name: standup
description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone.
---
# Work report (standup / ticket summary)
Ground every line in git. Do not reconstruct from conversation memory — commits
are the record.
## Gather
```bash
# Commits in the window (adjust dates; author matches "Hagernesh")
git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \
--pretty=format:"%h|%ad|%s" --date=short
# What each commit actually contains (subjects lie sometimes)
git show --stat --pretty=format:"%s" <hash> | head -12
# In-flight work = part of "today" even if uncommitted
git status --short
git log origin/dev..HEAD --oneline # branch commits not yet in dev
```
## Known pitfalls in this repo
- **Check subjects against contents.** Commit titles here sometimes mismatch the
diff (e.g. a commit titled "unload export" that actually contained ISO
container validation). Use `git show --stat` before reporting a title as fact.
- A day with no commits usually still has uncommitted/in-flight work — report it
as its own section with per-item status (done / uncommitted / blocked).
- Merge commits from other authors are noise; filter with `--author`.
## Output format
One table per day: `# | Task (plain language, not the commit subject verbatim) |
Commit / Status`. Follow with a short "carry-over / blocked" list naming what
blocks each item. Keep it ticket-ready: no jargon that needs the repo open to
decode.

View File

@@ -0,0 +1,59 @@
---
name: verify
description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting.
---
# Verify a change (EDR definition of done)
Run these in order. Report which you ran and what each said — never call
unverified work done.
## 1. Type-check exactly what you touched
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal
```
Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited
`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`:
```bash
pnpm turbo build --filter=@edr/types
```
## 2. Validate every raw SQL statement
Each new/edited `dataSource.query` / `manager.query` string must pass:
```bash
node .claude/skills/edr-db/query.cjs explain "<the statement with dummy params>"
```
## 3. Migration checklist (if you added one)
- Timestamp unique in `src/migrations/` **and** greater than
`node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`.
- DDL idempotent (`IF NOT EXISTS`, guarded backfills).
- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB
yourself or fully restart the API, then confirm with
`query.cjs columns <table>`.
## 4. Tests — honest bar
`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite
is not the bar. The bar: run the specs nearest what you touched and introduce
**no new failure**. If you touched a service constructor, update its `.spec.ts`
mocks (constructor-arity breaks are this repo's most common test regression).
## 5. Observe the behaviour
Compiling is not working. Hit the endpoint, drive the UI flow, or query the
resulting rows. If you genuinely could not observe it, say so explicitly in the
summary — do not imply it was seen working.
## 6. Before commit
- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this
repo (husky shims exist but no user hooks) — nothing will catch it for you.
- Lint the files you touched if in doubt: `pnpm turbo lint --filter=<pkg>`.
- Do not commit or push unless the user asked.

294
CLAUDE_NEW.md Normal file
View File

@@ -0,0 +1,294 @@
# EDR Platform — Developer Guide
> This file is the contract. If something here contradicts the code, the code is the
> truth and this file is a bug — fix it in the same PR.
## Overview
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
Management and Passenger Management applications, a payment microservice, plus shared
types, NestJS utilities, and React component libraries.
The freight domain is the largest and most active area. Its core flow is:
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
Fees (storage, demurrage, double handling, truck detention) and allocation rules
(warehouse/yard/zone) hang off the warehouse stage.
## Apps
| App | Package name | Purpose | Default port |
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
## Packages
| Package | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
| `@edr/prettier-config` | Shared Prettier configuration |
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
type in `packages/types/src` changes nothing for consumers until you rebuild:
```bash
pnpm turbo build --filter=@edr/types
```
If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
| Command | Description |
| --------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:freight:api` | Freight API only |
| `pnpm dev:freight:portal` | Freight portal only |
| `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm dev:passenger` | Passenger API + web |
| `pnpm dev:payment` | Payment API |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests (turbo) |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
```
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
gate-pass scenarios). Read the script before running one; several write real rows.
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- `psql` is not installed on the dev machine. To query the database, write a short Node
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
## Hard rules
These are non-negotiable. Everything else is a strong default.
- **pnpm only.** Never run `npm install` or `yarn`.
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
reach for `any` to make an error go away.
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
in every config and it has already corrupted this database twice (see *Migrations*).
All schema changes go through TypeORM migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** extend `BaseEntity` from `@edr/api-common``createdAt`, `updatedAt`,
`deletedAt` (soft delete).
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
TypeScript properties are `camelCase`.
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
offer the safe version.
## Architecture
### NestJS module shape
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
### Data access — the real model
There are two sanctioned ways to read and write, and you must pick the right one:
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
It carries one obligation:
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
so they join the caller's transaction.
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
no timeout and will wait forever.
### Migrations
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
- `migrationsRun: true`**migrations run automatically on API boot**, with
`migrationsTransactionMode: 'each'`.
- Consequences you must design for:
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
data migration can execute twice. Keep one instance.
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
backfills guarded by `WHERE col IS NULL`.
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
was recorded in `migrations` while its column was absent — it had been dropped out of band.
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
re-introduce the outage it fixed.
### Auth
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
Add a permission there before referencing it.
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
Ownership checks are separate from permission checks. A staff user passes
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
## Frontend conventions
- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version
before copying a snippet.
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
freight web apps. Prefer it over re-implementing a component.
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
`.message` and degrades to `"Request failed with status code 400"`. Use
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
keep the synchronous version — their bodies are already parsed JSON.
- Server-side guards must be reflected in the UI. If the API will reject the action, the
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
- Prefer disabling a control with a visible reason over silently hiding it.
## Notifications
In-app notifications resolve recipients from the company's **linked portal users**. If a
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
SMS and email still send, because they address the company's phone and email directly. Check
this before debugging a "missing notification".
## PDF generation
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
assume a headless browser exists.
## Adding a new module to a NestJS app
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` directly).
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common`
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
2. Export from `src/index.ts`.
3. Component is a functional component with a `ComponentNameProps` interface
(named-exported alongside the default).
## Definition of done
A change is done when **all** of these hold. State explicitly which you ran.
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
dev database without error.
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
something the new code reads — applied to the dev database, since watch mode will not run it.
4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**,
so a fully green suite is not the bar. Run the specs covering what you touched and confirm
you introduced no new failure.
5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these
automatically (see below), so run them yourself.
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
endpoint, or ran the query. If you could not, say so plainly.
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
the summary. Never describe unverified work as done.
### Hooks do not run
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
hand; do not assume the hook caught it.
## Known traps
| Trap | What happens | What to do |
| --- | --- | --- |
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
## Project skills
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
| Skill | Use for |
| --- | --- |
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
## Working style
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
from a filename.
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
trade-off before changing files.
- **Small, reviewable commits**, one logical change each, conventional message.
- **Branch from `dev`; PRs target `dev`.**
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs `freight.warehouse_inventory.grn_number`.
*
* AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but
* the column is absent on at least one environment - it was added, then dropped
* out-of-band (a stray `synchronize: true`, same class of damage that
* RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has
* the original recorded, it will never re-run it.
*
* Without the column, everything that reads or writes a GRN fails with
* `column ... grn_number does not exist`:
* - bulkReceive() -> INSERT names grn_number (receive to warehouse)
* - importQueueByStatuses() -> Unloaded + Dispatch queues
* - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs
* - grnDocument() -> GRN PDF
*
* Idempotent: a no-op on environments where the column survived.
*/
export class RepairGrnNumberColumn2090000000000 implements MigrationInterface {
name = 'RepairGrnNumberColumn2090000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
// Recover the GRN for rows received before the column existed: it was also
// written into the receive note as "GRN Number: <value>".
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
/**
* Deliberately a no-op. Dropping the column is what broke these environments
* in the first place, and the original 1828 migration already owns its own
* down(). Reverting this repair must not re-introduce the outage.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import { CustomerResetService } from "./customer-reset.service";
/**
* Staff-triggered password reset. The customer receives the code and sets their
* own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
throw new NotFoundException(
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
);
}
return { channel: dto.channel, maskedTarget };
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
@Injectable()
export class CustomerResetService {
private readonly logger = new Logger(CustomerResetService.name);
constructor(
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
) {}
/**
* Send a reset code to the company's primary contact. Returns the masked
* destination, or null when there is no eligible account for that channel.
*
* Unlike the public flow this reports failure honestly — the caller is an
* authenticated staff member, so there is nothing to enumerate.
*/
async sendResetToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
if (!profile) {
this.logger.warn(`Company ${companyId} has no primary contact profile`);
return null;
}
// Resolve through the same active-account gate the public flow uses, so a
// suspended customer cannot be reactivated by a staff-triggered reset.
const user = await this.forgotPasswordService.resolveActiveUserById(
profile.userId,
);
if (!user) {
this.logger.warn(
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
);
return null;
}
const target = await this.forgotPasswordService.requestReset(user, channel);
if (!target) return null;
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/** The channel the reset code is delivered over. */
export enum ResetChannel {
Email = "email",
Phone = "phone",
}
export class ForgotPasswordRequestDto {
@ApiProperty({
description: "Email, username, or phone number of the account to reset",
example: "name@company.com",
})
@IsString()
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class BackofficeResetPasswordDto {
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
* this API does not set). These routes drive freight's own email-or-phone OTP
* service instead, then hand back a ticket for IAM's public `set-password`.
*/
@ApiTags("auth")
@Controller("auth")
@Public()
export class ForgotPasswordController {
private readonly logger = new Logger(ForgotPasswordController.name);
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
description:
"Always reports success. An unknown, inactive, or channel-less account is " +
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
})
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
if (user) {
try {
await this.forgotPasswordService.requestReset(user, dto.channel);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
this.logger.error(
`Reset code delivery failed for user ${user.id}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
}
} else {
this.logger.log("Reset requested for an unknown or inactive account");
}
return { success: true };
}
@Post("forgot-password/verify")
@ApiOperation({
summary: "Exchange a valid reset code for a single-use set-password ticket",
description:
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
}
}

View File

@@ -0,0 +1,169 @@
import { randomBytes } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
*/
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Resolve an account that is actually eligible for a password reset.
*
* IAM's `set-password` handler flips `isActive: true` on the user as a side
* effect, so a reset on a deactivated account would silently resurrect it.
* Gating here — rather than at the set-password call — is what keeps that
* from being reachable. Mirrors IAM's own login lookup: match on any of
* email / username / phone, and require an active credential row.
*/
async resolveActiveUser(identifier: string): Promise<User | null> {
const id = identifier.trim();
if (!id) return null;
return await this.activeUserQuery()
.andWhere(
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
{ id },
)
.getOne();
}
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
async resolveActiveUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.activeUserQuery()
.andWhere("u.id = :userId", { userId })
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
* which would silently drop the `isActive` gate.
*/
private activeUserQuery() {
return this.userRepository
.createQueryBuilder("u")
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
.where("u.isActive = true")
.orderBy("u.createdAt", "DESC");
}
/** The address the code goes to, taken from the account — never from input. */
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
return user.phoneNumber ? { phone: user.phoneNumber } : null;
}
/**
* Send a reset code to the account's own email/phone. Returns the target so
* authenticated (backoffice) callers can echo a masked version; unauthenticated
* callers must discard it.
*
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
* upserts. A reset request therefore overwrites any pending signup code for
* the same address — last code sent wins. That is the pre-existing behaviour
* between any two flows sharing this table.
*/
async requestReset(
user: User,
channel: ResetChannel,
): Promise<OtpTarget | null> {
const target = this.targetFor(user, channel);
if (!target) return null;
await this.otpService.sendOtp(target);
return target;
}
/**
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
* spend on the public `PATCH /api/auth/set-password`.
*
* Minting a `UserVerification` row rather than writing `UserCredential`
* ourselves keeps IAM as the single owner of the password write path (old
* credential deactivation, argon hashing, changed-at bookkeeping).
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
// beyond what the request step already (deliberately) refuses to tell them.
throw new BadRequestException("Invalid verification code");
}
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
const code = randomBytes(24).toString("base64url");
const verificationCode = await hashPassword(code);
const userId = user.id;
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(UserVerification);
// Retire any outstanding codes so only the ticket we just minted can be
// spent — `findVerificationForPrimaryReset` reads the newest row.
await repo.update({ userId }, { isUsed: true });
await repo.insert({
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
isUsed: false,
attemptCount: 0,
});
});
this.logger.log(`Reset ticket minted for user ${userId}`);
return { userId, verificationCode: code };
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}
}

View File

@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
OtpModule,
],
controllers: [
FreightMeController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
],
})
export class FreightAuthModule {}

View File

@@ -1125,6 +1125,30 @@ export class BookingsService {
);
}
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
if (!ids.length) return;
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
for (const b of bookings) {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -1135,7 +1159,7 @@ export class BookingsService {
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
const result = await this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
@@ -1167,6 +1191,8 @@ export class BookingsService {
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
await this.attachHandoverFlags(result.items ?? []);
return result;
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */

View File

@@ -0,0 +1,88 @@
import { Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
const COMPANY_ID = "company-1";
/**
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
*/
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
({ where }: { where: Partial<Row> & { companyId: string } }) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
(where.status === undefined || row.status === where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
),
),
} as unknown as Repository<CompanyChangeRequest>;
}
function subject(rows: Row[]) {
return new CompanyChangeRequestRepository(mockRepositoryOver(rows));
}
describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
const rejected: Row = {
id: "rejected",
status: ChangeRequestStatus.Rejected,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
it("returns the pending request when one is open", async () => {
const pending: Row = {
id: "pending",
status: ChangeRequestStatus.Pending,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([rejected, pending]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("pending");
});
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("rejected");
});
it("returns null once a resubmit of a rejected request is approved", async () => {
const approved: Row = {
id: "approved",
status: ChangeRequestStatus.Approved,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([
rejected,
approved,
]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
it("returns null when the company has no requests", async () => {
const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
});

View File

@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
/**
* The company's latest "open" request — pending (locks the customer) or the
* most recent rejected one (drives the reapply banner + prefill). Approved
* requests are terminal and ignored here.
* most recent rejected one (drives the reapply banner + prefill).
*
* Only the company's newest request may be open. A rejection is superseded the
* moment the customer resubmits: that resubmit opens a *new* request, so once
* it is approved the newest request is terminal and nothing is open — even
* though the older rejected row still sits in the table as history.
*/
async findLatestOpenByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
const pending = await this.findPendingByCompanyId(companyId);
if (pending) return pending;
return this.repository.findOne({
where: { companyId, status: ChangeRequestStatus.Rejected },
const latest = await this.repository.findOne({
where: { companyId },
order: { createdAt: "DESC" },
});
return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
}
async findById(id: string): Promise<CompanyChangeRequest | null> {

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@nestjs/common";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.transporter]: "TR",
};
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Numbers per series letter: A00001..A99999, then B00001. */
const SERIES_SIZE = 99_999;
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const nextId = Number(result[0].next_id);
const offset = nextId - 1;
const seriesIndex = Math.floor(offset / SERIES_SIZE);
if (seriesIndex >= SERIES_LETTERS.length) {
throw new InternalServerErrorException(
`Company profile reference series exhausted for type "${type}"`,
);
}
const letter = SERIES_LETTERS[seriesIndex];
const number = (offset % SERIES_SIZE) + 1;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
return `${prefix}-${letter}${String(number).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {

View File

@@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity {
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* Official profile reference (e.g. "EX-A00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.

View File

@@ -593,7 +593,7 @@ export class ContractTransitionService {
if (!dto.otpPhone || !dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -0,0 +1,150 @@
# GPS Tracking (GT06) — Operations & Device Configuration
GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes
everything about how the service is deployed and how devices are pointed at it.
---
## 1. Why GPS needs its own dedicated TCP port
- **Not HTTP.** GT06 devices send binary frames
(`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`).
An HTTP server receiving these answers `400 Bad Request` and closes.
- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two
listeners on the same pair collide (`EADDRINUSE`). The REST API already owns
its port, so GPS traffic needs a separate one.
- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so
L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by
domain. Routing must happen at **Layer 4 (TCP)** by port.
- **DNS carries no port.** An A record maps a name to an IP only. The tracker
config must state the port explicitly (e.g. `gps.example.com:5023`).
### Operational requirements
| Item | Value |
| --- | --- |
| Protocol | Raw TCP (not HTTP, not TLS) |
| Default port | `5023` (configurable via `GT06_TCP_PORT`) |
| Listener bind | `0.0.0.0` inside the `freight-gps` container |
| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. |
---
## 2. Port configuration
`5023` is only this project's default — **not** a GT06 protocol requirement. The
listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured
with the same number.
Host and container ports are decoupled in `docker-compose.yaml`:
```yaml
freight-gps:
ports:
- "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
environment:
GT06_TCP_PORT: "5023" # pinned inside the container
```
- The **container** always listens on `5023`.
- The **host/public** port is configurable (443, 5023, 9000, …) via the root
`.env`'s `GT06_TCP_PORT`.
- This split is required because the image runs as a **non-root** user
(`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the
host port and forwards to `5023` inside.
- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the
actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`.
- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that
expect a TLS handshake on 443 may drop the connection.
---
## 3. Deployment topology
The GT06 listener runs as its own process (`dist/main.gps.js`, module
`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight`
DB with the API; the DB is the seam (ingester writes `gps_devices` /
`gps_positions`, API reads them).
```
freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
```
`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations.
Horizontal scale: each tracker holds one long-lived TCP connection with
per-socket session state, so N `freight-gps` replicas can run behind an L4 LB —
each device sticks to one replica. `ensureDevice` is safe under concurrency
(unique IMEI).
---
## 4. Device configuration (GT06 side)
Config is done by **SMS to the tracker's SIM**. Commands below are the canonical
Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware.
Default command password is usually `123456`.
Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN.
```
STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
APN,<apn># # 2. carrier data APN (add ,user,pass if needed)
SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
GPRSON,1# # 4. enable data
GPSON,1# # enable GPS
TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
```
Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#`
Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#`
### Verify from the server
```bash
docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
nc -vz gps.example.com 5023
curl -H "Authorization: Bearer <token>" https://api.example.com/api/gps/positions/latest
```
First login packet **auto-registers** the IMEI (no manual step). `online:true`
only when `lastSeenAt` < 5 min (computed at read time).
### Link a tracker to a vehicle (optional)
Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`):
```
PATCH /api/gps/devices/:id { "vehicleId": "<uuid>", "name": "Truck 03-ET" }
```
### Failure map
| Symptom | Cause |
| --- | --- |
| No SMS reply | SIM PIN on / no signal / wrong number |
| Replies but never connects | APN wrong, or `SERVER` port `GT06_TCP_PORT` |
| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
| Registered but `online:false` | packets blocked by firewall open inbound TCP |
| Wrong location / `positioned:false` | no GPS fix yet open sky, cold start ~12 min |
---
## 5. Security
- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
reach the port can inject fake positions.
- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
group to the SIM provider's **APN / IP range**.
- Trackers must use the same host+port as the server:
`SERVER,1,gps.example.com,<port>,0#`.
---
## 6. Edge (L4) termination
See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf)
for an Nginx `stream {}` example, and the AWS NLB notes in the same file.
Reminder: **L4 only** an HTTP proxy cannot route GT06.

View File

@@ -13,8 +13,10 @@ import {
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import {
AuthUserPayload,
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}

View File

@@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
* the handshake is authenticated in `handleConnection` and each socket joins a
* private `user:<id>` room the service targets.
* `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
* controller does not cover WebSockets; the handshake is authenticated in
* `handleConnection` and each socket joins a private `user:<id>` room the
* service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,

View File

@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
// axios defaults to no timeout — a hanging gateway would block the caller
// (and any transaction it sits in) indefinitely. Always bound the wait.
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
try {
const response = await axios.post(
url,
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
callbackUrl: "",
},
{
timeout,
headers: {
accept: "*/*",
"Content-Type": "application/json",

View File

@@ -50,6 +50,9 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
// A freshly issued code gets a fresh guess budget.
this.actionAttempts.delete(this.targetKey(target));
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
@@ -121,24 +124,44 @@ export class OtpService {
// ---------------------------------------------------------------------------
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
// contract signature). Unlike verifyOtp above — which marks a phone verified
// and leaves the code in place — this enforces a short TTL and consumes the
// code on success so it can never be replayed.
// contract signature, resetting a forgotten password). Unlike verifyOtp above
// — which marks a target verified and leaves the code in place — this enforces
// a TTL and consumes the code on success so it can never be replayed.
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
async verifyOtpForAction(phone: string, otp: string) {
const otpData = await this.otpRepository.findByPhone(phone);
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
// within its own TTL. `otp_verifications` has no attempt column, so the
// counter lives here and the code is burned once the budget is spent.
// Per-process: it resets on restart and is not shared across replicas — a
// persisted counter needs a migration on OtpVerification.
private readonly MAX_ACTION_ATTEMPTS = 5;
private readonly actionAttempts = new Map<string, number>();
private targetKey(target: OtpTarget): string {
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
}
async verifyOtpForAction(
target: OtpTarget,
otp: string,
ttlMs: number = this.ACTION_OTP_TTL_MS,
) {
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
if (!otpData) {
throw new BadRequestException(
"No verification code was requested for this phone",
target.email
? "No verification code was requested for this email"
: "No verification code was requested for this phone",
);
}
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
if (ageMs > ttlMs) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
@@ -146,11 +169,24 @@ export class OtpService {
}
if (otpData.otp !== otp) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
return { success: true };
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
@IsOptional()
@IsDateString()
gateOutTime?: string;
@ApiPropertyOptional({
description:
'Container bookings only: the operator chose not to weigh this truck. ' +
'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
})
@IsOptional()
@IsBoolean()
weighingSkipped?: boolean;
}

View File

@@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
// Reserve is retired from the operator flow — a stored export item advances
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
STORED: ['RESERVED', 'READY_FOR_LOADING'],
// READY_FOR_PICKUP is the way back out for an IMPORT item that was parked in
// storage from READY_FOR_PICKUP; without it, Store is a one-way door.
STORED: ['RESERVED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
@@ -150,6 +151,37 @@ export class HandoverService {
);
}
/**
* Reminder loop: until a self-haul handover is signed, re-send the sign
* notification (in-app + SMS + email) every 5 minutes. One reminder per
* booking per tick, newest unsigned handover's reference. Stops the moment
* signForBooking() stamps signed_at.
*
* NB: runs in every API instance — keep a single instance in dev or the
* customer is reminded once per instance per tick.
*/
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
async remindUnsignedHandovers(): Promise<void> {
try {
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
`SELECT DISTINCT ON (booking_id)
booking_id AS "bookingId", reference
FROM freight.booking_handovers
WHERE signed_at IS NULL
AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
ORDER BY booking_id, generated_at DESC`,
);
if (!rows.length) return;
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
for (const row of rows) {
await this.notifySignNeeded(row.bookingId, row.reference);
}
} catch (err) {
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
}
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource

View File

@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
/** Sent after the transaction commits so the gateway never blocks the receive. */
const pendingNotifications: Array<{
owner: {
phone?: string | null;
ownerName?: string | null;
bookingReference?: string | null;
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
};
booking: {
companyId?: string | null;
reference?: string | null;
hasFirstMile?: boolean;
hasLastMile?: boolean;
customerTruckAssignedAt?: string | null;
};
bookingId: string;
}> = [];
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
manager,
);
await this.notifyOwnerInventoryReceived({
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
// Queued, not sent here: an SMS/email round-trip inside the transaction
// holds capacity/location locks open for the whole gateway latency.
pendingNotifications.push({
owner: {
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
},
booking,
bookingId,
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
void this.notifyTruckAssignmentNeeded(booking, bookingId);
}
});
// Fan out after commit, un-awaited: the receive response must not wait on the
// SMS gateway. Both notifiers swallow their own errors.
for (const pending of pendingNotifications) {
void this.notifyOwnerInventoryReceived(pending.owner);
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
}
return result;
}
@@ -2326,6 +2357,27 @@ export class WarehouseInventoryService {
});
}
/**
* Self-haul = the customer's own truck collects the goods: either a truck
* assigned via the portal (customer_truck_assigned_at), or a walk-in truck
* registered at the gate on a booking with no EDR last-mile leg. EDR
* last-mile bookings are never self-haul.
*/
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const runner = manager ?? this.dataSource;
const [row]: Array<{ ok: number }> = await runner.query(
`SELECT 1 AS ok
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
AND (b.customer_truck_assigned_at IS NOT NULL
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
AND COALESCE(st.includes_last_mile, false) = false))`,
[bookingId],
);
return Boolean(row);
}
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
@@ -2335,19 +2387,17 @@ export class WarehouseInventoryService {
);
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
// Leaving = gate-out captured, with either a weighed gross or an explicit
// container weighing skip (bulk always weighs).
const isTruckLeaving =
Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
if (item.bookingId) {
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
// Self-haul = customer collects: a truck assigned via the portal, OR a
// walk-in truck registered at the gate on a booking with no EDR last mile.
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
@@ -2361,7 +2411,8 @@ export class WarehouseInventoryService {
// Authoritative weight match: the truck's net (gross tare) must equal the
// total VGM cargo weight of the containers selected as loaded on it.
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
// Skipped when the operator chose not to weigh (containers only).
if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
@@ -2431,13 +2482,10 @@ export class WarehouseInventoryService {
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
// assigned trucks and walk-in trucks registered manually at the gate
// (no portal assignment, no EDR last mile). Must be signed before leaving.
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
@@ -4422,14 +4470,17 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined) {
// Container bookings may skip the weighbridge entirely (weighingSkipped);
// bulk always weighs.
const weighingSkipped = dto.weighingSkipped === true;
if (dto.tareWeight === undefined && !weighingSkipped) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
@@ -4441,7 +4492,11 @@ export class WarehouseInventoryService {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
if (
!weighingSkipped &&
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
grossWeight == null
) {
throw new BadRequestException('Gross weight is required for truck exit');
}
@@ -4457,7 +4512,8 @@ export class WarehouseInventoryService {
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} t`,
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
@@ -4481,6 +4537,8 @@ export class WarehouseInventoryService {
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
// The weigh/skip decision is made at arrival and sticks for the exit.
weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
};
}

View File

@@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
},
{
fileKey: "commercial_license",
fileLabel: "Commercial License",
helpText: "Verified against the government trade system during registration.",
fileLabel: "Commercial Registration",
helpText:
"Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
helpText: "Verified against the government trade system during registration.",
helpText:
"Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
const CONTRACT_INTAKE_ENTITY = "contract_intake";
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
required: false,
}),
clearanceField(
"commercial_framework",
"Commercial Framework / Agreement",
1,
{
required: false,
},
),
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
required: false,
}),
@@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
constructor(private readonly dataSource: DataSource) { }
async run() {
await this.dataSource.transaction(async (manager) => {
@@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder {
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
for (const documentSetting of allSettings) {
await settingRepository.upsert(
@@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder {
});
if (!setting) {
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
throw new Error(
`file_upload_setting_seed_failed:${documentSetting.code}`,
);
}
await fieldRepository.delete({ settingId: setting.id });

View File

@@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'),
];
// D. Finance — payments + invoices
@@ -395,6 +396,7 @@ export const FREIGHT_PERMS = {
update: 'edr_freight_app:customers:update',
deactivate: 'edr_freight_app:customers:deactivate',
verify: 'edr_freight_app:customers:verify',
resetPassword: 'edr_freight_app:customers:reset-password',
},
payments: {
view: 'edr_freight_app:payments:view',

View File

@@ -0,0 +1,105 @@
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id" | "email" | "phone">;
}
/**
* Staff-triggered password reset. Sends a one-time code to the customer's
* primary contact; the customer picks their own new password. No credential is
* ever shown to or handled by staff.
*/
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset code sent",
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset code",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
return (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset code"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a one-time code to this customer&apos;s primary contact.
They choose their own new password you will not see it.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the code via"
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
description={company.phone ?? "No phone on the company record"}
/>
<Radio
value="email"
label="Email"
description={company.email ?? "No email on the company record"}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
The code goes to the primary contact&apos;s own email or phone, which
may differ from the company contact details shown above.
</Text>
<Button
color="edr-green"
loading={isPending}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset code
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -13,5 +13,9 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -250,7 +250,10 @@ const FreightSidebar = ({
<AppShell.Section
grow
component={ScrollArea}
type="never"
type="hover"
scrollbars="y"
scrollbarSize={6}
scrollHideDelay={500}
px="sm"
pb="md"
>

View File

@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
});
}
onClose();

View File

@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Release paper preview failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Handover document failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);

View File

@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -900,7 +900,7 @@ function EligibleTab({
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
}
setSelected(new Set());
@@ -1697,7 +1697,6 @@ function LoadedExportTab({
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Status</Table.Th>
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1739,19 +1738,6 @@ function LoadedExportTab({
{r.status}
</Badge>
</Table.Td>
{dispatchable && (
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
color="green"
loading={bulkDispatch.isPending}
onClick={() => dispatch([r.id])}
>
Dispatch
</Button>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
@@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}
@@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
};
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
!skipWeighing &&
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
});
return;
}
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
});
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: truckType.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
{hasContainerWeights && (
<Group gap="md" align="center">
<Text size="sm" fw={600}>Weigh truck?</Text>
<SegmentedControl
size="xs"
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
value={weighTruck}
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}

View File

@@ -5,7 +5,7 @@ import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};

View File

@@ -10,7 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
// Per-booking Load and Dispatch are retired: wagon loading happens in
// the train flow and dispatch at the train level (which already
// advances inventory). Only the remaining lifecycle actions render.
const rawNextAction = getNextInventoryAction(item);
const nextAction =
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>

View File

@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
`/payments/by-company/${id}/customer-view`,
RESET_PASSWORD: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-password`,
},
BILLING: {

View File

@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:customers:update",
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
resetPassword: "edr_freight_app:customers:reset-password",
},
payments: {
view: "edr_freight_app:payments:view",

View File

@@ -43,6 +43,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">

View File

@@ -12,6 +12,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
ResetPasswordResult,
} from "@/types/customer";
import {
CreateDropdownOptionDto,
@@ -2269,6 +2271,16 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
resetPassword: endpoint<
{ companyId: string; channel: ResetChannel },
ResetPasswordResult
>(
"customers",
"resetPassword",
({ companyId, channel }) =>
customersService.resetPassword(companyId, channel),
),
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus; note?: string },
CompanyProfile

View File

@@ -11,6 +11,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
ResetPasswordResult,
} from "@/types/customer";
const cleanParams = (params: object) =>
@@ -81,6 +83,22 @@ export const customersService = {
.then((r) => r.data);
},
/**
* Send a password-reset code to the company's primary contact. Staff never
* receive a credential — the customer sets their own password from the code.
*/
resetPassword(
companyId: string,
channel: ResetChannel,
): Promise<ResetPasswordResult> {
return apiClient
.post<ResetPasswordResult>(
URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
{ channel },
)
.then((r) => r.data);
},
setProfileStatus(
profileId: string,
status: ProfileStatus,

View File

@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/** The channel a customer's password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ResetPasswordResult {
channel: ResetChannel;
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
maskedTarget: string;
}
/** Mirrors backend `Company` (+ its `companyProfiles`). */
export interface Company {
id: string;

View File

@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
// once inspection passes. Import STORED is handled via the import queue.
if (isImport) return null;
// once inspection passes. An import item parked back into storage returns
// to pickup — otherwise Store would strand it with no action.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return inspected ? 'ready-for-loading' : null;
case 'RESERVED':
// Export loading is gated on a passed inspection.
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
grossWeight?: number;
netWeight?: number;
gateOutTime?: string;
/** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
weighingSkipped?: boolean;
}
/** Import branch: proof of delivery captured on customer pickup. */

View File

@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -252,6 +253,7 @@ const App = () => {
<Route element={<RedirectIfAuthed />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
{/* Signup-flow pages; reached while a session already exists */}

View File

@@ -0,0 +1,179 @@
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export type OtpChannel = "phone" | "email";
export const OTP_LENGTH = 6;
export interface OtpChannelSelectProps {
value: OtpChannel;
onChange: (channel: OtpChannel) => void;
disabled?: boolean;
label?: string;
}
/** Phone/email toggle deciding where the verification code is sent. */
export function OtpChannelSelect({
value,
onChange,
disabled,
label = "Send verification code via",
}: OtpChannelSelectProps) {
return (
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
{label}
</Text>
<SegmentedControl
fullWidth
disabled={disabled}
value={value}
onChange={(v) => onChange(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
);
}
export interface OtpChannelStepProps {
channel: OtpChannel;
/** Raw email or phone the code went to; masked before display. */
target: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
onBack: () => void;
onResend: () => void;
/** Seconds until resend is allowed; 0 enables the button. */
resendIn: number;
sending: boolean;
verifying: boolean;
error: string | null;
title?: string;
description?: string;
submitLabel: string;
}
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service.
*/
export default function OtpChannelStep({
channel,
target,
value,
onChange,
onVerify,
onBack,
onResend,
resendIn,
sending,
verifying,
error,
title,
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
const busy = sending || verifying;
return (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
{description ?? "Enter it to continue."}
</p>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={OTP_LENGTH}
type="number"
oneTimeCode
value={value}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={onChange}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || value.trim().length !== OTP_LENGTH}
onClick={onVerify}
>
{submitLabel}
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={busy}
onClick={onBack}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || busy}
onClick={onResend}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,41 @@
import { Check, X } from "lucide-react";
import { passwordRequirements } from "@/utils/passwordSchema";
export interface PasswordChecklistProps {
/** The current password value; the checklist hides itself when empty. */
value: string;
}
/** Live pass/fail list of the password rules, shown under a password field. */
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
if (!value) return null;
return (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(value);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({
});
}, [roles, nationality, startMutation]);
// Note: no "back to role selection" — once the draft is created the role(s)
// are fixed; the form's first-step Back is a no-op so progress never resets.
const handleBackToRoles = useCallback(() => { }, []);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
// draft, refreshes the nationality and creates only roles that don't exist yet.
const handleBackToRoles = useCallback(() => {
setStartError(null);
setPhase("nationality-role");
}, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
@@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,

View File

@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
},
USERS: {

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
/** Seconds a user must wait before another OTP can be requested. */
const DEFAULT_COOLDOWN_SECONDS = 60;
/**
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
* than wall-clock arithmetic, so it needs no Date.now().
*/
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (secondsLeft <= 0) return;
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [secondsLeft]);
return {
secondsLeft,
start: () => setSecondsLeft(seconds),
reset: () => setSecondsLeft(0),
};
}

View File

@@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const canApproveDelivery = booking.status === "COMPLETED";
// Visible from handover generation until the customer signs (flag is attached
// by the bookings list endpoint; false again the moment it's signed).
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";

View File

@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
initialStep?: CompanyStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
@@ -514,10 +511,6 @@ export default function CompanyProfileForm({
else setStep(stepOrder[currentIdx - 1]);
};
// Back is hidden on the first step during onboarding (can't return to role
// selection); otherwise always available.
const showBack = !(hideFirstStepBack && step === "company");
return (
<>
<form onSubmit={(e) => e.preventDefault()}>
@@ -851,17 +844,13 @@ export default function CompanyProfileForm({
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
) : (
<span />
)}
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
<Button
color="edr-green"
onClick={nextStep}

View File

@@ -0,0 +1,312 @@
import { type FormEvent, useState } from "react";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { api } from "@/services/api";
import type { ResetTicket } from "@/types/auth";
import { normaliseIdentifier } from "@/utils/identifier";
import { meetsAllRequirements } from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
type Stage = "identify" | "otp" | "password";
export default function ForgotPasswordPage() {
const navigate = useNavigate();
const [stage, setStage] = useState<Stage>("identify");
const [identifier, setIdentifier] = useState("");
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpCode, setOtpCode] = useState("");
// The reset ticket lives in memory only — persisting it would leave a
// password-change credential sitting in localStorage.
const [ticket, setTicket] = useState<ResetTicket | null>(null);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const resendCooldown = useResendCooldown();
/** The identifier as the API will see it — normalised once, reused everywhere. */
const normalised = normaliseIdentifier(identifier);
const sendCode = async () => {
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
setOtpCode("");
resendCooldown.start();
};
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
// so we always advance; a non-existent identifier simply never receives a code.
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSending(true);
try {
await sendCode();
setStage("otp");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
const handleResend = async () => {
setError(null);
setSending(true);
try {
await sendCode();
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
// Stage 2 — trade the code for a single-use ticket.
const handleVerify = async () => {
setError(null);
if (otpCode.trim().length !== OTP_LENGTH) {
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
return;
}
setVerifying(true);
try {
const result = await api.auth.verifyPasswordResetOtp.call({
identifier: normalised,
channel,
otp: otpCode.trim(),
});
setTicket(result);
setStage("password");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
// Stage 3 — spend the ticket on IAM's set-password.
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (!ticket) {
setError("Your reset session expired. Start again.");
setStage("identify");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setVerifying(true);
try {
await api.auth.resetPassword.call({
userId: ticket.userId,
// The API matches this against email / username / phone, so the typed
// identifier works regardless of which one it is.
email: normalised,
verificationCode: ticket.verificationCode,
newPassword: password,
confirmPassword,
});
navigate("/login", {
replace: true,
state: { passwordReset: true },
});
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
const identifierLabel =
channel === "email" ? "the email on your account" : "the phone on your account";
return (
<AuthShell
tagline="Recover your account"
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
>
<div className="flex w-full flex-col">
{stage === "identify" ? (
<form onSubmit={handleIdentify} className="flex w-full flex-col">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<KeyRound size={22} />
</span>
</div>
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Forgot your password?
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your email or phone number and we&apos;ll send you a code to
reset it.
</p>
</div>
<Stack gap="md">
<TextInput
label="Email or Phone"
placeholder="name@company.com or 09XXXXXXXX"
autoComplete="username"
required
disabled={sending}
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
/>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
label="Send the code to"
/>
<p className="text-xs text-gray-500">
The code goes to {identifierLabel}, which may differ from what you
typed above.
</p>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={sending}
disabled={!identifier.trim()}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
>
Send code
</Button>
<p className="text-center text-sm text-gray-500">
Remembered it?{" "}
<Link to="/login" className="font-semibold text-primary hover:underline">
Back to sign in
</Link>
</p>
</Stack>
</form>
) : null}
{stage === "otp" ? (
<OtpChannelStep
channel={channel}
target={normalised}
value={otpCode}
onChange={setOtpCode}
onVerify={handleVerify}
onBack={() => {
setStage("identify");
setError(null);
}}
onResend={handleResend}
resendIn={resendCooldown.secondsLeft}
sending={sending}
verifying={verifying}
error={error}
title="Enter your reset code"
description="Enter it to choose a new password."
submitLabel="Verify code"
/>
) : null}
{stage === "password" ? (
<form onSubmit={handleReset} className="flex w-full flex-col">
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Choose a new password
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Pick something strong you haven&apos;t used before.
</p>
</div>
<Stack gap="md">
<div>
<PasswordInput
label="New password"
placeholder="Create a strong password"
required
disabled={verifying}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<PasswordChecklist value={password} />
</div>
<PasswordInput
label="Confirm new password"
placeholder="Re-enter your password"
required
disabled={verifying}
error={
confirmPassword && confirmPassword !== password
? "Passwords do not match"
: undefined
}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={verifying}
disabled={
verifying ||
!meetsAllRequirements(password) ||
password !== confirmPassword
}
>
Reset password
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={verifying}
onClick={() => {
setStage("otp");
setError(null);
}}
>
Back
</Button>
</Stack>
</form>
) : null}
</div>
</AuthShell>
);
}

View File

@@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import AuthShell from "@/components/auth/AuthShell";
import { normaliseIdentifier } from "@/utils/identifier";
import { extractApiError } from "@/utils/result";
const EDR_LOGO = "/assets/edr-logo.png";
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
function normaliseIdentifier(raw: string): string {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
}
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
@@ -80,7 +70,7 @@ export default function LoginPage() {
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-gray-800">Password</span>
<Link
to="#"
to="/forgot-password"
className="text-xs font-semibold text-primary hover:underline"
>
Forgot password?

View File

@@ -1,4 +1,13 @@
import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Alert,
Box,
Button,
Group,
PasswordInput,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
import { useMemo, useState } from "react";
@@ -8,27 +17,19 @@ import { z } from "zod";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
import {
confirmPasswordField,
passwordField,
passwordRequirements,
samePassword,
} from "@/utils/passwordSchema";
const passwordSchema = z
.object({
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
password: passwordField,
confirmPassword: confirmPasswordField,
})
.refine((data) => data.password === data.confirmPassword, {
.refine(samePassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
@@ -54,7 +55,8 @@ export default function SetPasswordPage() {
const password = watch("password");
const requirements = useMemo(
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
() =>
passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
[password],
);
@@ -93,11 +95,21 @@ export default function SetPasswordPage() {
"Secure freight operations",
"Advanced authentication system",
],
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
stats: {
label: "Security Protection",
value: "256-bit",
footer: "Encrypted",
progress: "w-[98%]",
},
}}
>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>

View File

@@ -1,50 +1,38 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
Button,
PasswordInput,
PinInput,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Check,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
X,
} from "lucide-react";
import { AlertCircle, ArrowRight } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import type { SignupPayload } from "@/types/auth";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { api } from "@/services/api";
import {
confirmPasswordField,
passwordField,
samePassword,
} from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{
label: "One special character",
test: (v: string) => /[^A-Za-z0-9]/.test(v),
},
] as const;
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
@@ -61,43 +49,23 @@ const userSchema = z
en: z.string().min(2, "Name is required"),
am: z.string().nullable(),
}),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
password: passwordField,
confirmPassword: confirmPasswordField,
})
.refine((data) => data.password === data.confirmPassword, {
.refine(samePassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof userSchema>;
/** Mask all but the first 7 chars of an E.164 phone for display. */
const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
/** Mask the local part of an email for display (j***e@example.com). */
const maskEmail = (email: string) => {
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
};
type OtpChannel = "phone" | "email";
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
// phone number before the account is actually created. The account is only
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
// chosen channel before the account is actually created. The account is only
// created after the code is verified — the OTP is a hard requirement.
const [stage, setStage] = useState<"form" | "otp">("form");
const [pendingData, setPendingData] = useState<FormData | null>(null);
@@ -109,14 +77,7 @@ export default function SignupPage() {
const [verifying, setVerifying] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState(0);
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
const resendCooldown = useResendCooldown();
const {
register,
@@ -170,7 +131,7 @@ export default function SignupPage() {
setOtpChannel(channel);
setOtpCode("");
setOtpError(null);
setResendIn(60);
resendCooldown.start();
setStage("otp");
} catch (err) {
setError(extractApiError(err).message);
@@ -190,7 +151,7 @@ export default function SignupPage() {
: { phone: pendingData.phone },
);
setOtpCode("");
setResendIn(60);
resendCooldown.start();
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
@@ -202,8 +163,8 @@ export default function SignupPage() {
const confirmOtp = async () => {
if (!pendingData) return;
setOtpError(null);
if (otpCode.trim().length !== 6) {
setOtpError("Enter the 6-digit code we sent you.");
if (otpCode.trim().length !== OTP_LENGTH) {
setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
return;
}
setVerifying(true);
@@ -298,35 +259,11 @@ export default function SignupPage() {
disabled={sending}
/>
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
Send verification code via
</Text>
<SegmentedControl
fullWidth
disabled={sending}
value={channel}
onChange={(v) => setChannel(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
/>
<div>
<PasswordInput
@@ -337,37 +274,7 @@ export default function SignupPage() {
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div
key={req.label}
className="flex items-center gap-2"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
>
{req.label}
</span>
</div>
);
})}
</div>
) : null}
<PasswordChecklist value={passwordValue} />
</div>
<PasswordInput
@@ -412,87 +319,28 @@ export default function SignupPage() {
</Stack>
</form>
) : (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Verify your {otpChannel === "email" ? "email" : "phone"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a 6 - digit code to{" "}
<span className="font-medium text-gray-700">
{otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
.Enter it to finish creating your account.
</p>
</div>
{otpError ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify & create account
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={sending || verifying}
onClick={() => {
setStage("form");
setOtpError(null);
}}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || sending || verifying}
onClick={resendOtp}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
<OtpChannelStep
channel={otpChannel}
target={
otpChannel === "email"
? (pendingData?.email ?? "")
: (pendingData?.phone ?? "")
}
value={otpCode}
onChange={setOtpCode}
onVerify={confirmOtp}
onBack={() => {
setStage("form");
setOtpError(null);
}}
onResend={resendOtp}
resendIn={resendCooldown.secondsLeft}
sending={sending}
verifying={verifying}
error={otpError}
description="Enter it to finish creating your account."
submitLabel="Verify & create account"
/>
)}
</div>
</AuthShell>

View File

@@ -107,10 +107,12 @@ export function ReadonlyBookingView({
(isGeneralContract
? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery =
status === "COMPLETED" ||
Boolean(booking.handoverAwaitingSignature) ||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
// Approve delivery tracks the handover lifecycle exactly: the button appears
// the moment a handover is generated (truck arrival, or an operator's
// signature request) and disappears the moment the customer signs it. The
// backend flag counts only unsigned SELF_HAUL handovers, so no status
// heuristics are needed here.
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
? !booking.lastMileDeliveryAddress

View File

@@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map<string, string>([
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
// Company onboarding document codes (see file-upload-settings seeder).
["tin_certificate", "TIN Certificate"],
["commercial_license", "Commercial License"],
["commercial_license", "Commercial Registration"],
["business_license", "Business License / Trade License"],
["investment_license", "Investment License"],
["national_id", "National ID"],

View File

@@ -9,6 +9,7 @@ import {
Group,
Loader,
Paper,
Popover,
Select,
Stack,
Table,
@@ -17,6 +18,7 @@ import {
Title,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -31,6 +33,8 @@ import {
X,
} from "lucide-react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
import type { ContractListFilter } from "@/services/contracts.service";
@@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) {
export default function ContractsList() {
const navigate = useNavigate();
const { company } = useAuth();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// A contract can only be created under an approved profile — NewContractPage
// blocks every operation whose profile isn't "active". With none approved the
// page is reachable but unusable, so warn before sending the user there.
const profiles = company?.company?.companyProfiles ?? [];
const noActiveProfile =
profiles.length > 0 && !profiles.some((p) => p.status === "active");
const openNewContract = () =>
navigate("/contracts/new", { state: { fresh: true } });
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const nextSet = new Set(prev);
@@ -137,9 +153,11 @@ export default function ContractsList() {
const stats = useMemo(() => {
const items = data?.items ?? [];
const active = items.filter((c) =>
["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
c.status,
),
[
"CONTRACT_ACTIVE",
"FULLY_EXECUTED",
"ACTIVE_SHIPMENT_IN_PROGRESS",
].includes(c.status),
).length;
const pending = items.filter((c) =>
[
@@ -158,7 +176,7 @@ export default function ContractsList() {
return { active, pending, total };
}, [data]);
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const total = data?.meta?.total ?? data?.items?.length ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pageIndex = pagination.pageIndex;
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
@@ -175,19 +193,63 @@ export default function ContractsList() {
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Button
color="edr-green"
<Popover
opened={disclaimerOpen}
onChange={setDisclaimerOpen}
position="bottom-end"
width={340}
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
shadow="md"
withArrow
trapFocus
>
New Contract
</Button>
<Popover.Target>
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() =>
noActiveProfile
? setDisclaimerOpen((o) => !o)
: openNewContract()
}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
}}
>
New Contract
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group gap={8} wrap="nowrap" align="flex-start">
<AlertTriangle
size={18}
color="var(--mantine-color-edr-accent-6)"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<Text fz={14} fw={700} style={{ color: INK }}>
None of your profiles are active yet
</Text>
</Group>
<Text fz={13} c="dimmed">
Contracts can only be created under a profile EDR has
approved. You can continue, but every operation stays locked
until at least one profile is approved.
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
{/* Summary strip */}
@@ -378,7 +440,11 @@ export default function ContractsList() {
<Table.Tr>
<Table.Td colSpan={11}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Inbox
size={26}
color={MUTED}
style={{ opacity: 0.5 }}
/>
<Text fz={13} c="dimmed">
No contracts yet. Create one from New Contract.
</Text>
@@ -400,154 +466,159 @@ export default function ContractsList() {
const isOpen = expanded.has(c.id);
return (
<Fragment key={c.id}>
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={isOpen ? "Hide progress" : "Show progress"}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={
isOpen ? "Hide progress" : "Show progress"
}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
c.contractValidUntil,
).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={11} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} />
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
)}
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
@@ -564,7 +635,10 @@ export default function ContractsList() {
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
style={{
borderTop: `1px solid ${BORDER}`,
background: "#FCFDFE",
}}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
@@ -574,8 +648,7 @@ export default function ContractsList() {
data={["10", "25", "50"]}
value={String(pagination.pageSize)}
onChange={(v) =>
v &&
setPagination({ pageIndex: 0, pageSize: Number(v) })
v && setPagination({ pageIndex: 0, pageSize: Number(v) })
}
radius="md"
size="xs"

View File

@@ -26,7 +26,7 @@ export default function NationalitySelect({
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}

View File

@@ -77,6 +77,9 @@ import type {
SetPasswordPayload,
SignupPayload,
SignupResponse,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
ResetTicket,
} from "@/types/auth";
// ---------------------------------------------------------------------------
@@ -110,6 +113,21 @@ export const api = {
"setPassword",
authService.setPassword,
),
requestPasswordReset: endpoint<ForgotPasswordRequestPayload, void>(
"auth",
"requestPasswordReset",
authService.requestPasswordReset,
),
verifyPasswordResetOtp: endpoint<ForgotPasswordVerifyPayload, ResetTicket>(
"auth",
"verifyPasswordResetOtp",
authService.verifyPasswordResetOtp,
),
resetPassword: endpoint<SetPasswordPayload, void>(
"auth",
"resetPassword",
authService.resetPassword,
),
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
"auth",
"checkAvailability",

View File

@@ -3,11 +3,14 @@ import type {
AuthUser,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
ResetTicket,
SetPasswordPayload,
SignupPayload,
SignupResponse,
@@ -53,6 +56,31 @@ export const authService = {
return res.data.data;
},
// The three calls below drive the unauthenticated forgot-password flow.
// Responses under /api/auth are *flattened* by the API's response
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
requestPasswordReset: async (body: ForgotPasswordRequestPayload) => {
await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body);
},
verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => {
const res = await client.post<ResetTicket>(
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY,
body,
);
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
},
/**
* Spend the reset ticket. Distinct from `setPassword` above, which the
* authenticated post-signup flow drives through `useAuth` — this one carries
* its own userId/verificationCode and never touches the session.
*/
resetPassword: async (body: SetPasswordPayload) => {
await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body);
},
checkAvailability: async (params: CheckAvailabilityPayload) => {
const res = await client.get<CheckAvailabilityResponse>(
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,

View File

@@ -63,6 +63,25 @@ export interface SetPasswordPayload {
verificationCode: string;
}
/** The channel a password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
channel: ResetChannel;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
otp: string;
}
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
export interface ResetTicket {
userId: string;
verificationCode: string;
}
export interface GenerateVerificationCodePayload {
email: string;
phoneNumber: string;

View File

@@ -0,0 +1,22 @@
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
export function normaliseIdentifier(raw: string): string {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
}
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
/** Mask the local part of an email for display (j***e@example.com). */
export const maskEmail = (email: string) => {
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
};

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
export const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{
label: "One special character",
test: (v: string) => /[^A-Za-z0-9]/.test(v),
},
] as const;
/**
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
* — a password this accepts but the API rejects surfaces as an opaque 400.
*/
export const passwordField = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
export const confirmPasswordField = z
.string()
.min(1, "Please confirm your password");
export const samePassword = (data: {
password: string;
confirmPassword: string;
}) => data.password === data.confirmPassword;
/** Every requirement in {@link passwordRequirements} is satisfied. */
export const meetsAllRequirements = (value: string) =>
passwordRequirements.every((r) => r.test(value));

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
@@ -34,6 +34,12 @@ export class AgentsController {
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) {
return this.service.deleteAgent(id);
}
@Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -210,4 +210,11 @@ export class AgentsService {
},
});
}
async deleteAgent(id: string) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
await this.prisma.agent.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
@@ -26,7 +26,8 @@ export class ExcessBaggageAgentController {
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Body() dto: LogExcessBaggageDto) {
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
return this.service.logCharge(dto);
}
@@ -50,24 +51,6 @@ export class ExcessBaggageAgentController {
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Get('allowances')
@ApiOperation({ summary: 'List all baggage allowance rules' })
getAllowances() {
@@ -92,6 +75,24 @@ export class ExcessBaggageAgentController {
return this.service.deleteAllowance(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) {

View File

@@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LogExcessBaggageDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
@IsOptional() @IsString() agentId?: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })

View File

@@ -75,7 +75,7 @@ export class ExcessBaggageService {
const charge = await this.prisma.excessBaggageCharge.create({
data: {
bookingId: dto.bookingId,
agentId: dto.agentId,
agentId: dto.agentId ?? '',
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,

View File

@@ -154,43 +154,54 @@ export class SearchService {
) {
const [y, m, d] = dateStr.split('-').map(Number);
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
const now = new Date();
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
const daysAfter = 14 - daysBefore;
const windowStart = new Date(requestedDate);
windowStart.setDate(windowStart.getDate() - daysBefore);
if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
const totalPassengers = adultCount + (childCount ?? 0);
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
});
const baseWhere = {
status: 'SCHEDULED',
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
const FETCH_LIMIT = NEEDED * 5;
const [beforeCandidates, afterCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'desc' },
take: FETCH_LIMIT,
}),
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
take: FETCH_LIMIT,
}),
]);
const pickN = async (candidates: typeof beforeCandidates, limit: number) => {
const out: NonNullable<Awaited<ReturnType<typeof this.buildScheduleResult>>>[] = [];
for (const schedule of candidates) {
if (out.length >= limit) break;
const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality);
if (r?.hasAvailability) out.push(r);
}
return out;
};
const [before, after] = await Promise.all([
pickN(beforeCandidates, NEEDED),
pickN(afterCandidates, NEEDED),
]);
// before was fetched desc (closest first); reverse so result is chronological
return [...before.reverse(), ...after];
}
private async searchSchedules(
@@ -415,7 +426,7 @@ export class SearchService {
}
}
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -638,6 +649,10 @@ export class SearchService {
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const nationalityUpper = (nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
@@ -647,7 +662,14 @@ export class SearchService {
}
}
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
where: {
id: { in: Array.from(seatClassIdSet) },
isActive: true,
OR: [
{ nationalityType: null },
{ nationalityType: nationalityType },
],
},
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
@@ -725,6 +747,7 @@ export class SearchService {
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
nationality?: string,
): Array<{
coachTypeId: string;
coachTypeName: string;
@@ -749,8 +772,16 @@ export class SearchService {
});
}
const nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
coachType.seatClasses?.forEach((sc: any) => {
// Exclude classes that belong to the wrong nationality type
if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return;
if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name);
});
}
const result = [];
@@ -769,6 +800,7 @@ export class SearchService {
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,

View File

@@ -2,11 +2,12 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Eye } from 'lucide-react';
import { Plus, Edit, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { agentsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -48,6 +49,19 @@ export default function AgentsPage() {
const [editingAgent, setEditingAgent] = useState<any>(null);
const [editError, setEditError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => agentsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setDeleteConfirm({ isOpen: false, agent: null });
setDeleteError(null);
},
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'),
});
const editMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
onSuccess: () => {
@@ -122,6 +136,12 @@ export default function AgentsPage() {
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -169,6 +189,18 @@ export default function AgentsPage() {
emptyMessage="No agents found"
/>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }}
onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }}
title="Delete Agent"
message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteError ?? undefined}
/>
{/* Agent Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
{selected && (() => {

View File

@@ -2,13 +2,14 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Send, Trash2 } from 'lucide-react';
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING',
@@ -22,9 +23,16 @@ export default function ExcessBaggagePage() {
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const user = useAuthStore((s) => s.user);
const [waiveModal, setWaiveModal] = useState<any>(null);
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const [logModal, setLogModal] = useState(false);
const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false });
const [logError, setLogError] = useState<string | null>(null);
const [resendModal, setResendModal] = useState<any>(null);
const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
@@ -37,6 +45,17 @@ export default function ExcessBaggagePage() {
}),
});
const logMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setLogModal(false);
setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false });
setLogError(null);
},
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
const waiveMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
@@ -51,7 +70,12 @@ export default function ExcessBaggagePage() {
const resendMutation = useMutation({
mutationFn: (id: string) => excessBaggageApi.resendLink(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setResendSuccess(true);
setResendError(null);
},
onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'),
});
const deleteMutation = useMutation({
@@ -115,7 +139,7 @@ export default function ExcessBaggagePage() {
label: 'Resend Link',
icon: Send,
variant: 'secondary' as const,
onClick: (c: any) => resendMutation.mutate(c.id),
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
show: (c: any) => c.status === 'PENDING',
},
{
@@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}>
Log Excess Luggage
</ActionButton>
</div>
<div className="card">
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() {
emptyMessage="No excess baggage charges found"
/>
{/* Log Excess Luggage Modal */}
<Modal isOpen={logModal} onClose={() => setLogModal(false)} title="Log Excess Luggage" size="sm">
<div className="space-y-4">
{user && (
<div className="text-sm text-muted-foreground">
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
)}
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
</div>
</div>
</Modal>
{/* Resend Link Modal */}
<Modal isOpen={!!resendModal} onClose={() => setResendModal(null)} title="Resend Payment Link" size="sm">
{resendModal && (
<div className="space-y-4">
{resendSuccess ? (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
✓ Payment link resent successfully. Expiry extended by 20 minutes.
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
Resend payment link for booking{' '}
<span className="font-mono font-semibold text-foreground">{resendModal.booking?.bookingRef}</span>?
</p>
<div className="text-sm space-y-1">
{resendModal.contactPhone && <div className="text-muted-foreground">📱 {resendModal.contactPhone}</div>}
{resendModal.contactEmail && <div className="text-muted-foreground">✉ {resendModal.contactEmail}</div>}
</div>
<p className="text-xs text-muted-foreground">Amount: <span className="font-semibold">{formatCurrency(resendModal.totalMinor, resendModal.currency)}</span>. Expiry will be extended by 20 minutes.</p>
{resendError && <p className="text-sm text-red-600 dark:text-red-400">{resendError}</p>}
</>
)}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setResendModal(null)}>Close</ActionButton>
{!resendSuccess && (
<ActionButton icon={Send} loading={resendMutation.isPending} onClick={() => resendMutation.mutate(resendModal.id)}>
Resend
</ActionButton>
)}
</div>
</div>
)}
</Modal>
{/* Waive Modal */}
<Modal
isOpen={!!waiveModal}

View File

@@ -29,12 +29,12 @@ export default function LoginPage() {
const [emailFocused, setEmailFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
const [forgotEmail, setForgotEmail] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotSent, setForgotSent] = useState(false);
const [forgotFocused, setForgotFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
const [forgotIdentifier, setForgotIdentifier] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotSent, setForgotSent] = useState(false);
const [forgotFocused, setForgotFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
@@ -66,13 +66,13 @@ export default function LoginPage() {
setForgotLoading(true);
setForgotError('');
try {
await iamAuthApi.forgotPassword(forgotEmail);
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setForgotError(
msg === 'user_not_found'
? 'No account found with that email address.'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
);
} finally {
@@ -209,7 +209,7 @@ export default function LoginPage() {
<div className="flex justify-end mt-2">
<button
type="button"
onClick={() => { setView('forgot'); setForgotEmail(email); setError(''); }}
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
>
Forgot password?
@@ -260,7 +260,7 @@ export default function LoginPage() {
Reset your password
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Enter your email address and we&apos;ll send a reset link to the phone number on your account.
Enter your email or phone number and we&apos;ll send a reset link to the phone number on your account.
</p>
</div>
@@ -295,10 +295,10 @@ export default function LoginPage() {
)}
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
{/* Email field */}
{/* Email or phone field */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Email address
Email or phone number
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
forgotFocused
@@ -306,15 +306,15 @@ export default function LoginPage() {
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type="email"
value={forgotEmail}
onChange={(e) => { setForgotEmail(e.target.value); setForgotError(''); }}
type="text"
value={forgotIdentifier}
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
onFocus={() => setForgotFocused(true)}
onBlur={() => setForgotFocused(false)}
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="name@edr.com"
placeholder="name@edr.com or +251..."
required
autoComplete="email"
autoComplete="username"
/>
</div>
</div>
@@ -322,9 +322,9 @@ export default function LoginPage() {
{/* Submit */}
<button
type="submit"
disabled={forgotLoading || !forgotEmail}
disabled={forgotLoading || !forgotIdentifier.trim()}
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: forgotLoading || !forgotEmail
style={{ background: forgotLoading || !forgotIdentifier.trim()
? 'rgb(20,113,76)'
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
}}

View File

@@ -140,7 +140,7 @@ export default function PassengersPage() {
</div>
),
},
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },

View File

@@ -9,6 +9,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
interface Schedule {
id: string;
@@ -440,7 +441,7 @@ export default function SchedulesPage() {
label: 'Departure',
sortable: true,
render: (schedule: Schedule) => (
<span className="font-mono text-sm">{new Date(schedule.departureAt).toLocaleString()}</span>
<span className="font-mono text-sm">{formatDateTime(schedule.departureAt)}</span>
),
},
{
@@ -448,7 +449,7 @@ export default function SchedulesPage() {
label: 'Arrival',
sortable: true,
render: (schedule: Schedule) => (
<span className="font-mono text-sm">{new Date(schedule.arrivalAt).toLocaleString()}</span>
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
),
},
{
@@ -641,7 +642,7 @@ export default function SchedulesPage() {
}
}}
title="Cancel Schedule"
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`}
confirmText="Cancel Schedule"
isDanger={true}
isLoading={cancelScheduleMutation.isPending}
@@ -656,7 +657,7 @@ export default function SchedulesPage() {
deleteConfirm.isBulk
? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.`
: `Are you sure you want to delete this schedule departing on ${
deleteConfirm.item ? new Date(deleteConfirm.item.departureAt).toLocaleString() : ''
deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : ''
}?`
}
confirmText="Delete"

View File

@@ -166,10 +166,13 @@ export default function TariffRatesPage() {
},
{
key: 'coachType', label: 'Coach Type',
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
render: (c: any) => {
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
return <span className="text-sm">{ct ? `${ct.code}${ct.name}` : c.coachTypeId}</span>;
},
},
{
key: 'bedPosition', label: 'Berth Position',
key: 'bedPosition', label: 'Bed Position',
render: (c: any) => c.bedPosition
? <span className="font-mono text-sm">{c.bedPosition}</span>
: <span className="text-muted-foreground text-xs">Standard</span>,
@@ -215,8 +218,9 @@ export default function TariffRatesPage() {
},
];
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId)
?? editingClass?.coachType;
const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed');
return (
<div className="space-y-6">
@@ -224,7 +228,7 @@ export default function TariffRatesPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
<p className="text-muted-foreground">
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy
</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
@@ -237,7 +241,7 @@ export default function TariffRatesPage() {
<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 name, nationality, berth position..."
placeholder="Search by name, nationality, bed position..."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -311,17 +315,17 @@ export default function TariffRatesPage() {
{isBedCoach && (
<div>
<label className="label">Berth Position *</label>
<label className="label">Bed Position *</label>
<select
className="input"
value={selectedBedPosition}
onChange={(e) => setSelectedBedPosition(e.target.value)}
required={isBedCoach}
>
<option value="">Select berth position</option>
<option value="">Select bed position</option>
{(selectedCoachType?.code === 'HBC'
? BED_POSITIONS
: (['UPPER', 'LOWER'] as const)
: (['Upper','Middle', 'Lower'] as const)
).map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}

View File

@@ -38,13 +38,6 @@ export default function TicketsPage() {
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
const { data: agentData } = useQuery({
queryKey: ['agent-me'],
queryFn: () => apiClient.get<any>('/agents/me'),
enabled: !!user,
retry: false,
});
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
@@ -133,11 +126,8 @@ export default function TicketsPage() {
const handleExcessSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!excessTicket) return;
const agentId = agentData?.id;
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
await excessMutation.mutateAsync({
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
agentId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
});
@@ -353,15 +343,12 @@ export default function TicketsPage() {
key: 'contact',
label: 'Contact',
render: (ticket: any) => {
const phone = ticket.booking?.passenger?.phone || 'N/A';
const email = ticket.booking?.passenger?.email || 'N/A';
const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '';
const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '';
return (
<div>
<div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
</div>
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
</div>
);
},
@@ -486,7 +473,7 @@ export default function TicketsPage() {
const actions = [
{
label: 'Baggage',
label: 'Luggage',
onClick: openExcessModal,
variant: 'secondary' as const,
icon: Package,
@@ -912,14 +899,9 @@ export default function TicketsPage() {
<div className="text-sm text-muted-foreground">
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
</div>
{agentData && (
{user && (
<div className="text-sm text-muted-foreground">
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
</div>
)}
{!agentData && (
<div className="text-sm text-amber-600 dark:text-amber-400">
No agent profile linked to your account.
Agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>

View File

@@ -4,8 +4,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
export const iamAuthApi = {
forgotPassword: (email: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
// `identifier` can be an email address or a phone number — the IAM accepts
// either in the `email` field of the forgot-password body.
forgotPassword: (identifier: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
resetPassword: (data: {
userId: string;

View File

@@ -216,6 +216,7 @@ export const agentsApi = {
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
create: (data: any) => apiClient.post<any>('/agents', data),
update: (id: string, data: any) => apiClient.patch<any>(`/agents/${id}`, data),
delete: (id: string) => apiClient.delete(`/agents/${id}`),
getShifts: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/shifts`),
openShift: (agentId: string, data: any) => apiClient.post<any>(`/agents/${agentId}/shifts/open`, data),
closeShift: (shiftId: string, data: any) => apiClient.post<any>(`/agents/shifts/${shiftId}/close`, data),

View File

@@ -10,7 +10,10 @@ export const passengersApi = {
if (filters?.role) params.append('role', filters.role);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
if ((filters as any)?.gender) params.append('gender', (filters as any).gender);
if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality);
if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom);
if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo);
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
},
@@ -21,4 +24,8 @@ export const passengersApi = {
update: (id: string, data: Partial<Passenger.IPassenger>) => {
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
},
delete: (id: string, cascade = false) => {
return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`);
},
};

View File

@@ -19,21 +19,21 @@ export const formatDateTime = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
return format(d, 'MMM dd, yyyy h:mm a');
};
export const formatDateTimeShort = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'dd MMM yy HH:mm');
return format(d, 'dd MMM yy h:mm a');
};
export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
return format(d, 'MMM dd, yyyy h:mm a');
};
export const getStatusColor = (status: string): string => {

View File

@@ -188,7 +188,7 @@ export default function ConfirmationPage() {
if (!bookingId || !pnr) return null;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Success Header */}
@@ -456,26 +456,29 @@ export default function ConfirmationPage() {
</div>
</div>
{/* Action Buttons */}
<div className="mb-6">
<button
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary w-full flex items-center justify-center gap-2 relative"
>
{isGeneratingVoucher ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<FileText className="w-4 h-4" />
<span>Download Voucher</span>
</>
)}
</button>
</div>
{/* Action Buttons — voucher/ticket only makes sense once the booking is
actually confirmed; a pending-payment booking has no real ticket yet. */}
{isConfirmed && (
<div className="mb-6">
<button
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary w-full flex items-center justify-center gap-2 relative"
>
{isGeneratingVoucher ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<FileText className="w-4 h-4" />
<span>Download Voucher</span>
</>
)}
</button>
</div>
)}
{/* New Booking Button */}
<button

View File

@@ -2,6 +2,7 @@
import { usePathname } from 'next/navigation';
import { ProgressIndicator } from '@/components/ProgressIndicator';
import { PageTransition } from '@/components/PageTransition';
export default function BookingLayout({
children,
@@ -31,7 +32,7 @@ export default function BookingLayout({
<ProgressIndicator currentStep={currentStep} />
</div>
)}
{children}
<PageTransition>{children}</PageTransition>
</div>
);
}

View File

@@ -13,6 +13,30 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const COUNTRIES = [
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
'Latvia','Lebanon','Lesotho','Liberia','Libya','Liechtenstein','Lithuania','Luxembourg','Madagascar','Malawi',
'Malaysia','Maldives','Mali','Malta','Marshall Islands','Mauritania','Mauritius','Mexico','Micronesia','Moldova',
'Monaco','Mongolia','Montenegro','Morocco','Mozambique','Myanmar','Namibia','Nauru','Nepal','Netherlands',
'New Zealand','Nicaragua','Niger','Nigeria','North Korea','North Macedonia','Norway','Oman','Pakistan','Palau',
'Palestine','Panama','Papua New Guinea','Paraguay','Peru','Philippines','Poland','Portugal','Qatar','Romania',
'Russia','Rwanda','Saint Kitts and Nevis','Saint Lucia','Saint Vincent and the Grenadines','Samoa','San Marino',
'Sao Tome and Principe','Saudi Arabia','Senegal','Serbia','Seychelles','Sierra Leone','Singapore','Slovakia',
'Slovenia','Solomon Islands','Somalia','South Africa','South Korea','South Sudan','Spain','Sri Lanka','Sudan',
'Suriname','Sweden','Switzerland','Syria','Taiwan','Tajikistan','Tanzania','Thailand','Timor-Leste','Togo',
'Tonga','Trinidad and Tobago','Tunisia','Turkey','Turkmenistan','Tuvalu','Uganda','Ukraine','United Arab Emirates',
'United Kingdom','United States','Uruguay','Uzbekistan','Vanuatu','Vatican City','Venezuela','Vietnam',
'Yemen','Zambia','Zimbabwe',
] as const;
function daysInGCMonth(y: number, m: number) {
return new Date(y, m, 0).getDate();
}
@@ -554,6 +578,12 @@ const passengerSchema = z.object({
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
}
if (data.passportExpiryDate) {
const expiry = new Date(data.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passportExpiryDate'] });
}
}
}
});
@@ -994,7 +1024,7 @@ export default function PassengersPage() {
if (!formInitialized || faydaCompleting) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-lg mx-auto text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
@@ -1013,7 +1043,7 @@ export default function PassengersPage() {
<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">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
<h1 className="section-title">Passenger details</h1>
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
{fields.map((field, index) => {
@@ -1312,11 +1342,15 @@ export default function PassengersPage() {
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
<input
<select
{...register(`passengers.${index}.passportCountry`)}
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
placeholder="e.g., Djibouti"
/>
>
<option value="">Select country</option>
{COUNTRIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
{errors.passengers?.[index]?.passportCountry && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
)}
@@ -1336,8 +1370,12 @@ export default function PassengersPage() {
<input
type="date"
{...register(`passengers.${index}.passportExpiryDate`)}
className="input-field"
className={`input-field ${errors.passengers?.[index]?.passportExpiryDate ? 'border-red-500' : ''}`}
min={new Date().toISOString().split('T')[0]}
/>
{errors.passengers?.[index]?.passportExpiryDate && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportExpiryDate?.message}</p>
)}
</div>
</div>
</div>

View File

@@ -72,10 +72,10 @@ export default function PaymentPage() {
// split equally across both legs. This guarantees leg totals are consistent with the
// per-passenger breakdown rows and the overall reviewed total.
const outboundBaseFare = isRoundTrip
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
: 0;
const inboundBaseFare = isRoundTrip
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
: 0;
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
@@ -188,10 +188,10 @@ export default function PaymentPage() {
if (!bookingId && !pnr) {
return (
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
<div className="booking-page 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>
<p className="text-gray-600 dark:text-gray-400">Loading payment details...</p>
</div>
</div>
);
@@ -307,11 +307,11 @@ export default function PaymentPage() {
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
<span>{formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
<span>{formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
</div>
</div>
)}
@@ -372,10 +372,10 @@ export default function PaymentPage() {
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="booking-page pb-28 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
<h1 className="section-title">Complete payment</h1>
{/* Reservation confirmation banner */}
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">

View File

@@ -197,17 +197,12 @@ export default function ResultsPage() {
// Alternatives are surfaced whenever a leg returns no exact-date results.
const alternativeOutbound: Schedule[] =
!!results && outboundSchedules.length === 0
? results?.alternativeOutbound || []
? results?.alternativeOutbound || results?.outboundAlternatives || []
: [];
const alternativeInbound: Schedule[] =
isRoundTrip && !!results && inboundSchedules.length === 0
? results?.alternativeInbound || []
? results?.alternativeInbound || results?.inboundAlternatives || []
: [];
const requestedDate: string =
(results && results.requestedDate) || searchData.date;
const requestedReturnDate: string =
(results && results.requestedReturnDate) || searchData.returnDate || "";
const isOneWayNoOutbound =
!isRoundTrip && !!results && outboundSchedules.length === 0;
// Round-trip: show results view if either leg has exact results OR alternatives.
@@ -298,7 +293,17 @@ export default function ResultsPage() {
// For round trip inbound, proceed with both schedules
if (isRoundTrip && !isOutbound) {
setInboundSchedule(scheduleData);
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
// return seat selection page shows the same prices as the outbound leg.
const inboundScheduleData = outboundScheduleData
? {
...scheduleData,
baseFareAdult: outboundScheduleData.baseFareAdult,
baseFareChild: outboundScheduleData.baseFareChild,
coachTypes: outboundScheduleData.coachTypes,
}
: scheduleData;
setInboundSchedule(inboundScheduleData);
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
} else {
// For one-way
@@ -529,7 +534,7 @@ export default function ResultsPage() {
e.stopPropagation();
handleSelect(classModal, isOutbound);
}}
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
className="btn-primary w-full mt-3 text-sm active:scale-[0.98]"
>
<span>
{isRoundTrip && isOutbound
@@ -647,9 +652,9 @@ export default function ResultsPage() {
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 md:gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{schedule.departureAt
? formatTime(schedule.departureAt)
: "--:--"}
@@ -664,9 +669,9 @@ export default function ResultsPage() {
</div>
</div>
<div className="flex-1 flex flex-col items-center">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<div className="flex-1 min-w-0 flex flex-col items-center">
<div className="flex items-center gap-1 md:gap-2 mb-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
<span>{durationStr}</span>
</div>
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
@@ -674,15 +679,15 @@ export default function ResultsPage() {
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
</div>
{schedule.stops && schedule.stops.length > 0 && (
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
<MapPin className="w-4 h-4" />
<div className="flex items-center gap-1 mt-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
<MapPin className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
<span>{schedule.stops.length - 2} stops</span>
</div>
)}
</div>
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{schedule.arrivalAt
? formatTime(schedule.arrivalAt)
: "--:--"}
@@ -744,7 +749,7 @@ export default function ResultsPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Progress Header */}
@@ -943,28 +948,19 @@ export default function ResultsPage() {
!!results &&
outboundSchedules.length === 0 &&
inboundSchedules.length === 0 &&
alternativeOutbound.length === 0 &&
alternativeInbound.length === 0;
(results?.alternativeOutbound || []).length === 0 &&
(results?.alternativeInbound || []).length === 0;
if (isRoundTripNoResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains found for your selected dates or route.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains for your trip. Try adjusting
your dates or route.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
</div>
@@ -975,34 +971,20 @@ export default function ResultsPage() {
}
if (isOneWayNoOutbound) {
const requestedDateLabel = requestedDate
? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: "your selected date";
const hasAlternatives = alternativeOutbound.length > 0;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="booking-page">
{renderClassModal()}
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="card text-center mb-8 max-w-3xl mx-auto">
<div className="hidden sm:flex w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains available on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}</span>.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedDateLabel}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel date
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change date
</button>
</div>
@@ -1031,24 +1013,15 @@ export default function ResultsPage() {
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2.5 text-sm text-gray-600 dark:text-gray-400">
<Calendar className="w-4 h-4 flex-shrink-0 text-gray-400" />
<span>No trains found matching your search. Try adjusting your dates or route.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains matching your search criteria.{" "}
<br /> Try adjusting your dates or route.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
</div>
@@ -1059,7 +1032,7 @@ export default function ResultsPage() {
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{renderClassModal()}
@@ -1172,29 +1145,13 @@ export default function ResultsPage() {
{outboundSchedules.length === 0 &&
alternativeOutbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedDate
? format(
new Date(`${requestedDate}T00:00:00`),
"EEEE, MMMM d, yyyy",
)
: "your selected date"}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel dates
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">
@@ -1272,29 +1229,13 @@ export default function ResultsPage() {
{inboundSchedules.length === 0 &&
alternativeInbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedReturnDate
? format(
new Date(`${requestedReturnDate}T00:00:00`),
"EEEE, MMMM d, yyyy",
)
: "your selected return date"}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel dates
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">

View File

@@ -445,7 +445,9 @@ export default function ReviewPage() {
const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild };
const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
});
setReviewedTotal(computedTotal, passengerFares);
@@ -560,6 +562,14 @@ export default function ReviewPage() {
const isFreeChild = isPackageBooking
? isPkgFreeChild(i)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
// Per-leg fares for round trips
const outboundFare: number | null = isRoundTrip
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
: null;
const inboundFare: number | null = isRoundTrip
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
: null;
const seatFare = getPassengerSeatFare(p);
const passengerTotal = isPackageBooking
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
@@ -582,6 +592,19 @@ export default function ReviewPage() {
{formatFare(passengerTotal, displayCurrency)}
</span>
</div>
{/* Round-trip: show outbound + inbound breakdown */}
{isRoundTrip && !isFreeChild && (
<div className="mt-1 space-y-0.5 pl-2">
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Outbound</span>
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span> Return</span>
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
</div>
</div>
)}
</div>
);
})}
@@ -613,10 +636,10 @@ export default function ReviewPage() {
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="booking-page pb-28 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
<h1 className="section-title">
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
</h1>

View File

@@ -608,6 +608,12 @@ export default function SearchPage() {
reValidateMode: "onSubmit",
defaultValues: {
tripType: "ONE_WAY",
// originStationId/destinationStationId must default to "" rather than being omitted —
// Zod's base string type check runs before .min(1, "..."), so an `undefined` value hits
// its generic "Required" message instead of our custom one. An empty string is still a
// string, so .min() (and its custom message) is what actually fires.
originStationId: "",
destinationStationId: "",
adultCount: 1,
childCount: 0,
// No default nationality — the user must explicitly pick one. Left blank (not a valid
@@ -827,19 +833,21 @@ export default function SearchPage() {
/>
)}
{/* ── 90vh hero with banner image ── */}
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
{/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */}
{/* Round trip stacks an extra Return Date field into the widget on desktop, which grows
upward from its bottom-anchored position — give the hero extra height there so the
widget's top edge doesn't creep up into the sticky header. */}
widget's top edge doesn't creep up into the sticky header. On mobile the widget is
in normal flow (not bottom-anchored), so this only applies at md: and up. */}
<section
className={`relative ${
tripType === "ROUND_TRIP"
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
: "h-[94vh] min-h-[560px]"
? "md:h-[90vh] md:min-h-[560px]"
: "md:h-[94vh] md:min-h-[560px]"
}`}
>
{/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden">
{/* Background image with zoom — desktop only; mobile drops the hero image entirely
so the booking widget can sit at the top and use the available space. */}
<div className="hidden md:block absolute inset-0 overflow-hidden">
<div
className="w-full h-full bg-cover bg-center animate-bg-zoom"
style={{
@@ -852,12 +860,12 @@ export default function SearchPage() {
}}
/>
</div>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */}
<div className="hidden md:block absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Hero headline — top area */}
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
{/* Hero headline — desktop only; removed on mobile along with the banner image */}
<div className="hidden md:block relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight drop-shadow-lg max-w-2xl animate-fade-in-up">
Where are you
<br className="hidden sm:block" /> headed today?
@@ -867,14 +875,28 @@ export default function SearchPage() {
</p>
</div>
{/* ── Widget — absolutely positioned at bottom with margin ── */}
{/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at
bottom on desktop over the hero image. z-[35] sits above the floating
support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it
never covers either. Modals opened from inside the widget (date picker
etc.) are portaled to <body> — see ModernDatePicker — so they aren't
capped by this wrapper's own stacking context. ── */}
<div
className="absolute bottom-8 left-0 right-0 z-[50] px-4 md:px-6"
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[35] px-4 md:px-6"
ref={widgetRef}
>
<div className="max-w-6xl mx-auto">
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
<div className="md:hidden mb-3">
<h1 className="text-2xl font-extrabold text-gray-900 dark:text-gray-100 leading-tight">
Where are you headed today?
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Book your train journey across East Africa
</p>
</div>
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-none md:shadow-2xl border border-white/20 overflow-visible">
{error && (
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
<span></span>
@@ -950,6 +972,11 @@ export default function SearchPage() {
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
@@ -998,6 +1025,11 @@ export default function SearchPage() {
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
@@ -1019,8 +1051,14 @@ export default function SearchPage() {
}}
minDate={new Date()}
placeholder="Select date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5">
@@ -1047,6 +1085,7 @@ export default function SearchPage() {
: new Date()
}
placeholder="Select return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
@@ -1079,7 +1118,7 @@ export default function SearchPage() {
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg disabled:opacity-50"
className="btn-primary w-full text-sm"
>
<Search className="w-5 h-5" />
Search
@@ -1182,6 +1221,7 @@ export default function SearchPage() {
}}
minDate={new Date()}
placeholder="Departure"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
@@ -1219,7 +1259,7 @@ export default function SearchPage() {
<button
type="submit"
disabled={isLoading}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
>
<Search className="w-5 h-5" />
Search
@@ -1324,6 +1364,7 @@ export default function SearchPage() {
}}
minDate={new Date()}
placeholder="Select date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
@@ -1357,6 +1398,7 @@ export default function SearchPage() {
: new Date()
}
placeholder="Select date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
@@ -1482,7 +1524,7 @@ export default function SearchPage() {
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
>
<Search className="w-5 h-5" />
Search

View File

@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
import Image from "next/image";
import CustomModal from "@/components/CustomModal";
import { Skeleton } from "@/components/Skeleton";
import { isChild } from "@/utils/fare-utils";
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
@@ -451,16 +452,15 @@ export default function SeatsPage() {
// the new seat map data has finished loading.
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
const firstClass = matchedType.classes?.[0];
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
const updatedSchedule = {
...(currentSchedule as any),
selectedCoachTypeId: newCoachTypeId,
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "",
selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
seatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
};
@@ -921,7 +921,7 @@ export default function SeatsPage() {
const positionLabel = newSeat?.bedPosition
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
: "This seat";
const legMultiplier = isRoundTrip ? 2 : 1;
const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
setModalState({
isOpen: true,
@@ -1328,15 +1328,16 @@ export default function SeatsPage() {
useEffect(() => {
if (isRoundTrip) {
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
router.push("/booking/search");
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
}
} else {
if (!selectedSchedule || !passengers.length) {
router.push("/booking/search");
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
}
}
}, [
isRoundTrip,
isPackageBooking,
selectedSchedule,
outboundSchedule,
inboundSchedule,
@@ -1712,7 +1713,7 @@ export default function SeatsPage() {
if (
isRoundTrip
? !outboundSchedule || !inboundSchedule || !passengers.length
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
: !selectedSchedule || !passengers.length
)
return null;
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
</button>
</div>
{isLoading ? (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Loading seat map...
</p>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 24 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
</div>
) : error ? (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">

View File

@@ -3,16 +3,18 @@
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
import { Footer } from '@/components/Footer';
const styles = `
.contact-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
background: #ffffff;
text-align: center;
color: #111827;
}
.dark .contact-hero {
background: #111827;
color: #f3f4f6;
}
@@ -370,6 +372,7 @@ export default function Contact() {
</div>
</section>
</main>
<Footer />
</>
);
}

View File

@@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [identifier, setIdentifier] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
@@ -16,13 +16,13 @@ export default function ForgotPasswordPage() {
setLoading(true);
setError('');
try {
await iamAuthApi.forgotPassword(email);
await iamAuthApi.forgotPassword(identifier.trim());
setSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(
msg === 'user_not_found'
? 'No account found with that email address.'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
);
} finally {
@@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter your email and we&apos;ll send a reset link to the phone number on your account.
Enter your email or phone number and we&apos;ll send a reset link to the phone number on your account.
</p>
</div>
@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() {
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email or phone number</label>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
type="text"
value={identifier}
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
className="input-field"
placeholder="your@email.com"
autoComplete="email"
placeholder="your@email.com or +251..."
autoComplete="username"
required
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading || !email}>
<button type="submit" className="btn-primary w-full" disabled={loading || !identifier.trim()}>
{loading ? 'Sending...' : 'Send reset link'}
</button>
</form>

View File

@@ -26,19 +26,17 @@
}
.btn-ghost {
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)];
}
.btn-ghost:hover {
background-color: rgba(20, 113, 76, 0.1);
.booking-page {
@apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
}
@media (prefers-color-scheme: dark) {
.btn-ghost:hover {
background-color: rgba(20, 113, 76, 0.2);
}
.booking-container {
@apply container mx-auto px-4 max-w-6xl;
}
.input-field {
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
}
@@ -162,7 +160,18 @@
opacity: 1;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -188,6 +197,10 @@
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
.animate-fade-in-up {
animation: fade-in-up 0.35s ease-out;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;

View File

@@ -13,7 +13,7 @@ import { useSearchParams } from "next/navigation";
* hop is browser-dependent and can be stripped) must NOT be used here.
*
* It fires immediately (no delay) and paints a bare white full-screen cover
* above the sticky header (z-[60]) — no portal chrome, no text on the happy
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
* path. A short message shows only when the link is missing/untrusted.
*
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the

View File

@@ -2,10 +2,11 @@ import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import AppSidebar from '@/components/AppSidebar';
import MobileTopBar from '@/components/MobileTopBar';
import BottomTabBar from '@/components/BottomTabBar';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import SupportWidget from '@/features/support/SupportWidget';
import SupportWidget from '@/features/support/SupportWidgetLazy';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
@@ -21,7 +22,7 @@ export default function RootLayout({
const nonce = headers().get('x-nonce') ?? undefined;
return (
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased flex flex-col min-h-screen">
<body className="font-sans antialiased">
<script
nonce={nonce}
dangerouslySetInnerHTML={{
@@ -47,11 +48,14 @@ export default function RootLayout({
/>
<Providers>
<LoadingIndicator />
<AppHeader />
<main className="flex-1">
{children}
</main>
<Footer />
<AppSidebar />
<div className="lg:pl-64 flex flex-col min-h-screen">
<MobileTopBar />
<main className="flex-1">
{children}
</main>
<BottomTabBar />
</div>
<SupportWidget />
</Providers>
</body>

View File

@@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
if (!map.has(key)) {
map.set(key, {
coachTypeId: ct?.id ?? key,
coachTypeName: ct?.name ?? tier.seatType,
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
coachTypeCode: ct?.code ?? '',
coachTypeType: ct?.type ?? 'passenger',
tiers: [],
@@ -270,90 +270,121 @@ function PriceTiersPanel({
}
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Coach Type</h2>
{groups.map((group) => {
const CoachIcon = getCoachIcon(group.coachTypeType);
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
const isSelected = selectedId === group.coachTypeId;
return (
<div
key={group.coachTypeId}
className={`rounded-xl border-2 overflow-hidden transition-colors ${
allSoldOut
? 'border-gray-200 dark:border-gray-700 opacity-50'
: isSelected
? 'border-primary'
: 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
}`}
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
>
{/* Coach type header */}
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/60">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
isSelected ? 'bg-primary' : 'bg-primary/10'
}`}>
<CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-900 dark:text-white">
{formatCoachTypeLabel(group.coachTypeType)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
From {formatPrice(group.minPrice * priceMultiplier, group.currency)}
{allSoldOut && <span className="ml-2 text-red-500 font-semibold">· Sold out</span>}
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">Choose Coach Type</h2>
<div className="grid grid-cols-1 gap-4">
{groups.map((group, index) => {
const CoachIcon = getCoachIcon(group.coachTypeType);
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
const isSelected = selectedId === group.coachTypeId;
return (
<div
key={group.coachTypeId}
role="button"
tabIndex={allSoldOut ? -1 : 0}
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
onKeyDown={(e) => {
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
setSelectedId(isSelected ? null : group.coachTypeId);
}
}}
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
: isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer'
: 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer'
}`}
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }}
>
{/* Radio indicator */}
{!allSoldOut && (
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
isSelected ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<span className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${isSelected ? 'border-primary' : 'border-gray-300 dark:border-gray-600 group-hover:border-primary/50'
}`}>
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
</span>
)}
</div>
{/* All available classes for this coach type */}
<div className="px-4 py-3 space-y-2">
{group.tiers.map((tier) => {
const soldOut = tier.availableSeats === 0;
return (
<div
key={tier.id}
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
>
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
<p className="text-xs">
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
{soldOut ? (
<span className="ml-2 font-bold text-red-500">Sold out</span>
) : (
<span className="ml-2 text-gray-400">{tier.availableSeats} left</span>
)}
</p>
<div className="flex flex-col">
<div className="flex items-start gap-2 pr-2">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${isSelected
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
}`}>
<CoachIcon className={`w-4 h-4 transition-colors ${isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 tracking-wider">
{formatCoachTypeLabel(group.coachTypeType)}
</p>
{allSoldOut && (
<span className="text-xs font-bold text-red-500 mt-0.5 block">Sold out</span>
)}
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
<span className={`text-sm font-bold tracking-tight ${isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
}`}>
{((group.minPrice * priceMultiplier) / 100).toFixed(2)}
</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{group.currency}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Book Now — only when this group is selected */}
{isSelected && !allSoldOut && (
<div className="px-4 pb-4">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
>
Book Now <ArrowRight className="w-4 h-4" />
</button>
{/* Class options — always visible, matching results page style */}
{group.tiers.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200/60 dark:border-gray-700/60">
<div className="space-y-1.5">
{group.tiers.map((tier) => {
const soldOut = tier.availableSeats === 0;
return (
<div
key={tier.id}
className={`flex flex-col py-1 px-1 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 ${soldOut ? 'opacity-50' : ''}`}
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</span>
{soldOut ? (
<span className="text-xs font-bold text-red-500 mt-0.5">Sold out</span>
) : (
<div className="flex items-baseline gap-1 mt-0.5">
<span className="text-sm font-bold tabular-nums text-primary">
{((tier.priceMinor * priceMultiplier) / 100).toFixed(2)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">{tier.currency}</span>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{!isSelected && !allSoldOut && (
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
Click to select this coach
</p>
)}
{isSelected && !allSoldOut && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
>
Book Now <ArrowRight className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
<style>{`
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
`}</style>
</div>
);
}
@@ -407,7 +438,7 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.name ?? tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
</div>
@@ -416,26 +447,26 @@ function PassengerCountModal({
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
].map(({ label, sub, value, min, max, set }) => (
<div key={label} className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
<p className="text-xs text-gray-400">{sub}</p>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => set(Math.max(min, value - 1))}
disabled={value <= min}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
</button>
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
<button type="button" onClick={() => set(value + 1)}
disabled={value >= max}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
+
</button>
</div>
<div key={label} className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
<p className="text-xs text-gray-400">{sub}</p>
</div>
))}
<div className="flex items-center gap-3">
<button type="button" onClick={() => set(Math.max(min, value - 1))}
disabled={value <= min}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
</button>
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
<button type="button" onClick={() => set(value + 1)}
disabled={value >= max}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
+
</button>
</div>
</div>
))}
{freeChildren > 0 && (
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
@@ -457,15 +488,14 @@ function PassengerCountModal({
{/* Departure Station */}
<div>
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
Departure Station
</label>
<select
value={departureStationId}
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
}`}
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
}`}
>
<option value="">Select your boarding station</option>
{stations.map((s) => (
@@ -480,10 +510,10 @@ function PassengerCountModal({
)}
<button type="button" onClick={() => {
if (!departureStationId) { setShowStationError(true); return; }
const station = stations.find(s => s.id === departureStationId);
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
}}
if (!departureStationId) { setShowStationError(true); return; }
const station = stations.find(s => s.id === departureStationId);
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
}}
disabled={loading || adultCount + childCount < 1}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
@@ -523,8 +553,10 @@ export default function PackageDetailPage() {
// For the passenger modal, use the cheapest available tier in the selected coach type group
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
// Representative tier for the modal header (cheapest available)
const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null;
// Representative tier for the modal: cheapest available in the selected group
const representativeTier = selectedGroup?.tiers
.filter((t) => t.availableSeats > 0)
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null;
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
@@ -745,9 +777,9 @@ export default function PackageDetailPage() {
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
{pkg.priceTiers.length
? formatPrice(
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
pkg.priceTiers[0].currency,
)
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
pkg.priceTiers[0].currency,
)
: "—"}
</p>
</div>

View File

@@ -1,9 +1,37 @@
import { Suspense } from 'react';
import dynamic from 'next/dynamic';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import SearchPage from '@/app/booking/search/page';
import PackagesSection from '@/components/PackagesSection';
export default async function Home() {
// Below-the-fold on the landing page — code-split out of the initial bundle
// needed to render/hydrate the (above-the-fold) search form.
const PackagesSection = dynamic(() => import('@/components/PackagesSection'));
// Skeleton for the search form while stations are prefetched server-side —
// keeps first paint on a slow connection from being a blank page, and
// approximates the real form's height to avoid layout shift once it streams in.
function SearchFormSkeleton() {
return (
<div className="w-full max-w-4xl mx-auto animate-pulse">
<div className="rounded-2xl border border-gray-100 dark:border-gray-800 p-6 space-y-4">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
<div className="grid md:grid-cols-2 gap-4">
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
</div>
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-xl w-40" />
</div>
</div>
);
}
// Isolates the server-side stations prefetch behind its own Suspense boundary
// (a real async Server Component, unlike a plain `await` in the page body) so
// Next.js can stream: the shell flushes immediately with SearchFormSkeleton,
// then the real search form + hydrated stations data stream in once ready —
// instead of blocking the whole page's TTFB on that one fetch.
async function StationsPrefetch({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@@ -18,10 +46,18 @@ export default async function Home() {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense>
<SearchPage />
<PackagesSection />
</Suspense>
{children}
</HydrationBoundary>
);
}
export default function Home() {
return (
<Suspense fallback={<SearchFormSkeleton />}>
<StationsPrefetch>
<SearchPage />
<PackagesSection />
</StationsPrefetch>
</Suspense>
);
}

View File

@@ -1,214 +0,0 @@
"use client";
import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useAuthStore } from "@/lib/auth-store";
import ChangePasswordModal from "@/components/ChangePasswordModal";
export default function AppHeader() {
const [isOpen, setIsOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const { user, isAuthenticated, initialize, logout } = useAuthStore();
useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark");
setIsDark(isDarkMode);
initialize();
}, [initialize]);
const toggleTheme = () => {
const html = document.documentElement;
const isDarkMode = html.classList.contains("dark");
if (isDarkMode) {
html.classList.remove("dark");
setIsDark(false);
localStorage.setItem("theme", "light");
} else {
html.classList.add("dark");
setIsDark(true);
localStorage.setItem("theme", "dark");
}
};
return (
<header className="sticky top-0 z-[60] bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
href="/"
className="flex items-center hover:opacity-80 transition-opacity"
>
<Image
src="/edr-logo.png"
alt="Ethio-Djibouti Railway"
width={140}
height={48}
className="h-14 w-auto"
priority
/>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8">
<Link
href="/booking/lookup"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
My Booking
</Link>
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center justify-center p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Help & FAQ"
>
<HelpCircle className="w-5 h-5" />
</Link>
{/* Theme Toggler */}
<button
onClick={toggleTheme}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title={isDark ? "Light mode" : "Dark mode"}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
{/* Auth */}
{isAuthenticated && user ? (
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 p-1.5 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
{user.fullName?.toUpperCase().charAt(0) || 'U'}
</div>
<ChevronDown className="hidden sm:block w-4 h-4 text-gray-100" />
</button>
{showUserMenu && (
<div className="absolute right-0 top-full mt-2 w-56 z-50 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
</div>
<div className="p-2">
<button
onClick={() => {
setShowUserMenu(false);
setShowChangePassword(true);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<KeyRound className="h-4 w-4" />
Change Password
</button>
<button
onClick={() => {
setShowUserMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
>
<LogOut className="h-4 w-4" />
Sign out
</button>
</div>
</div>
)}
</div>
) : (
<div className="hidden md:flex items-center gap-2">
<Link
href="/login"
className="px-3 py-1.5 text-sm font-medium text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
>
Sign in
</Link>
<Link
href="/register"
className="px-3 py-1.5 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
>
Register
</Link>
</div>
)}
{/* Mobile Menu Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
{isOpen ? (
<X className="w-5 h-5" />
) : (
<Menu className="w-5 h-5" />
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
<Link
href="/booking/lookup"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
My Booking
</Link>
<Link
href="/help"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Help
</Link>
{!isAuthenticated && (
<>
<Link
href="/login"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Sign in
</Link>
<Link
href="/register"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Register
</Link>
</>
)}
</div>
)}
</div>
</div>
<ChangePasswordModal
isOpen={showChangePassword}
onClose={() => setShowChangePassword(false)}
/>
</header>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import {
Home,
Phone,
Ticket,
HelpCircle,
Moon,
Sun,
KeyRound,
LogOut,
ChevronDown,
Check,
} from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/auth-store';
import ChangePasswordModal from '@/components/ChangePasswordModal';
import { BOOKING_STEPS } from '@/components/ProgressIndicator';
// Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
// get a vertical step list instead of the standard nav highlighting.
const BOOKING_STEP_MAP: Record<string, string> = {
'/booking/search': 'search',
'/booking/results': 'results',
'/booking/auth-check': 'passengers',
'/booking/passengers': 'passengers',
'/booking/seats': 'seats',
'/booking/review': 'review',
'/booking/payment': 'payment',
'/booking/confirmation': 'confirmation',
};
const NAV_LINKS = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
{ href: '/contact', label: 'Contact', icon: Phone },
{ href: '/help', label: 'Help', icon: HelpCircle },
];
export default function AppSidebar() {
const pathname = usePathname();
const [isDark, setIsDark] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const { user, isAuthenticated, initialize, logout } = useAuthStore();
useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark'));
initialize();
}, [initialize]);
const toggleTheme = () => {
const html = document.documentElement;
const nextDark = !html.classList.contains('dark');
html.classList.toggle('dark', nextDark);
localStorage.setItem('theme', nextDark ? 'dark' : 'light');
setIsDark(nextDark);
};
const currentStepId = BOOKING_STEP_MAP[pathname ?? ''];
const currentStepIndex = currentStepId ? BOOKING_STEPS.findIndex((s) => s.id === currentStepId) : -1;
return (
<aside className="hidden lg:flex w-64 fixed inset-y-0 left-0 z-40 flex-col bg-[rgb(20_113_76)] dark:bg-gray-900 border-r border-[rgb(16_89_60)] dark:border-gray-800">
<Link href="/" className="flex items-center px-5 h-20 flex-shrink-0 hover:opacity-80 transition-opacity">
<Image
src="/edr-logo.png"
alt="Ethio-Djibouti Railway"
width={140}
height={48}
className="h-16 w-auto"
priority
/>
</Link>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-white/15 text-white'
: 'text-gray-100 hover:bg-white/10 hover:text-white'
}`}
>
<Icon className="w-5 h-5" />
{label}
</Link>
);
})}
{currentStepIndex >= 0 && (
<div className="mt-6 pt-4 border-t border-white/15">
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-200/80">
Your booking
</p>
<ol className="space-y-1">
{BOOKING_STEPS.map((step, index) => {
const isComplete = index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<li key={step.id} className="flex items-center gap-2.5 px-3 py-1.5">
<span
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors ${
isComplete
? 'bg-white text-[rgb(20,113,76)]'
: isCurrent
? 'border-2 border-white text-white'
: 'border border-white/30 text-white/50'
}`}
>
{isComplete ? <Check className="w-3 h-3" /> : index + 1}
</span>
<span
className={`text-sm ${
isCurrent ? 'text-white font-semibold' : isComplete ? 'text-gray-100' : 'text-white/50'
}`}
>
{step.name}
</span>
</li>
);
})}
</ol>
</div>
)}
</nav>
<div className="flex-shrink-0 border-t border-white/15 p-3 space-y-1">
<button
onClick={toggleTheme}
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-100 hover:bg-white/10 hover:text-white transition-colors"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'Light mode' : 'Dark mode'}
</button>
{isAuthenticated && user ? (
<div className="relative">
<button
onClick={() => setShowUserMenu((v) => !v)}
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/10 transition-colors"
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
{user.fullName?.toUpperCase().charAt(0) || 'U'}
</div>
<span className="flex-1 text-left text-sm font-medium text-white truncate">{user.fullName}</span>
<ChevronDown className={`w-4 h-4 text-gray-200 transition-transform ${showUserMenu ? 'rotate-180' : ''}`} />
</button>
{showUserMenu && (
<div className="absolute bottom-full left-0 mb-2 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl overflow-hidden">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
</div>
<div className="p-2">
<button
onClick={() => {
setShowUserMenu(false);
setShowChangePassword(true);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<KeyRound className="h-4 w-4" />
Change Password
</button>
<button
onClick={() => {
setShowUserMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
>
<LogOut className="h-4 w-4" />
Sign out
</button>
</div>
</div>
)}
</div>
) : (
<div className="flex items-center gap-2 px-1 pt-1">
<Link
href="/login"
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
>
Sign in
</Link>
<Link
href="/register"
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
>
Register
</Link>
</div>
)}
</div>
<ChangePasswordModal
isOpen={showChangePassword}
onClose={() => setShowChangePassword(false)}
/>
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show More