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

View File

@@ -0,0 +1,69 @@
'use client';
import { Home, Phone, Ticket, User } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
// The linear, one-screen-at-a-time booking flow — each of these pages already
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
// so the tab bar hides rather than stacking a second bottom bar underneath.
const LINEAR_FLOW_PREFIXES = [
'/booking/search',
'/booking/results',
'/booking/seats',
'/booking/passengers',
'/booking/auth-check',
'/booking/review',
'/booking/payment',
'/booking/confirmation',
];
export default function BottomTabBar() {
const pathname = usePathname() ?? '';
const { isAuthenticated } = useAuthStore();
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
if (isInLinearFlow) return null;
const tabs = [
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
{
href: isAuthenticated ? '/profile' : '/login',
label: isAuthenticated ? 'Account' : 'Sign in',
icon: User,
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
},
];
return (
<>
{/* In-flow spacer so page content/Footer can't end up hidden behind the
fixed bar below — self-contained here so it only exists when the
bar itself is actually rendered. */}
<div className="lg:hidden h-16" aria-hidden="true" />
<nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
<ul className="flex items-stretch">
{tabs.map(({ href, label, icon: Icon, match }) => {
const isActive = match(pathname);
return (
<li key={href} className="flex-1">
<Link
href={href}
className={`flex flex-col items-center justify-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors ${
isActive ? 'text-primary' : 'text-gray-500 dark:text-gray-400'
}`}
>
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} strokeWidth={isActive ? 2.5 : 2} />
{label}
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}

View File

@@ -1,163 +0,0 @@
'use client';
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { LanguageSwitcher } from './LanguageSwitcher';
import { getTranslation, Language } from '@/lib/i18n';
import { useLanguage } from '@/lib/i18n';
export function LandingNav() {
const [isOpen, setIsOpen] = useState(false);
const [lang, setLang] = useState<Language>('en');
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
const { getLang } = useLanguage();
const t = (key: string) => getTranslation(lang, key);
useEffect(() => {
setMounted(true);
setLang(getLang());
const handleLanguageChange = (e: any) => setLang(e.detail);
window.addEventListener('languageChange', handleLanguageChange);
return () => window.removeEventListener('languageChange', handleLanguageChange);
}, [getLang]);
useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark');
setIsDark(isDarkMode);
}, []);
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');
}
};
const menuItems = [
{ label: t('nav.home'), href: '/' },
{ label: t('nav.services'), href: '/services' },
{ label: t('nav.about'), href: '/about' },
{ label: t('nav.contact'), href: '/contact' },
];
if (!mounted) return null;
return (
<nav className="sticky top-0 z-40 bg-[rgb(20_113_76)] border-b border-[rgb(16_89_60)] 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 gap-3 hover:opacity-80 transition-opacity">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-100">
Book your train journey with us
</p>
</div>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
{item.label}
</Link>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
<LanguageSwitcher />
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center gap-1.5 px-3 py-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>
<Link
href="/booking/search"
className="hidden sm:block px-4 py-2 bg-white text-[rgb(20_113_76)] text-sm font-medium rounded-lg transition-all duration-200 transform hover:scale-105 hover:bg-gray-100"
>
{t('nav.bookNow')}
</Link>
{/* 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 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{item.label}
</Link>
))}
<Link
href="/help"
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{t('nav.help')}
</Link>
<Link
href="/booking/search"
className="block px-4 py-2 bg-white text-[rgb(20_113_76)] font-medium rounded-lg transition-colors hover:bg-gray-100"
onClick={() => setIsOpen(false)}
>
{t('nav.bookNow')}
</Link>
</div>
)}
</div>
</div>
</nav>
);
}

View File

@@ -1,23 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { Suspense, useEffect, useRef, useState } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
export function LoadingIndicator() {
// Minimum time the bar stays visible once shown, so very fast local
// transitions don't flash imperceptibly (standard practice for top-loading
// progress bars).
const MIN_VISIBLE_MS = 300;
function LoadingIndicatorInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isVisible, setIsVisible] = useState(false);
const shownAtRef = useRef<number | null>(null);
// Detects the START of a navigation: a same-origin link click gives
// instant feedback on tap, before the RSC fetch even begins. Browser
// back/forward (popstate) is covered the same way.
useEffect(() => {
const handleStart = () => setIsVisible(true);
const handleEnd = () => setIsVisible(false);
const handleClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
window.addEventListener('beforeunload', handleStart);
window.addEventListener('load', handleEnd);
const anchor = (e.target as HTMLElement | null)?.closest('a');
if (!anchor) return;
if (anchor.target && anchor.target !== '_self') return;
if (anchor.hasAttribute('download')) return;
const href = anchor.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
let url: URL;
try {
url = new URL(href, window.location.href);
} catch {
return;
}
if (url.origin !== window.location.origin) return;
if (url.pathname + url.search === window.location.pathname + window.location.search) return;
shownAtRef.current = Date.now();
setIsVisible(true);
};
const handlePopState = () => {
shownAtRef.current = Date.now();
setIsVisible(true);
};
document.addEventListener('click', handleClick, true);
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('beforeunload', handleStart);
window.removeEventListener('load', handleEnd);
document.removeEventListener('click', handleClick, true);
window.removeEventListener('popstate', handlePopState);
};
}, []);
// Detects the END of a navigation: pathname/search settling to a new value
// means the route transition has completed.
useEffect(() => {
if (!isVisible) return;
const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS;
const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed);
const timer = setTimeout(() => setIsVisible(false), remaining);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams]);
if (!isVisible) return null;
return (
@@ -28,3 +77,11 @@ export function LoadingIndicator() {
</div>
);
}
export function LoadingIndicator() {
return (
<Suspense fallback={null}>
<LoadingIndicatorInner />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import Link from 'next/link';
import Image from 'next/image';
// AppSidebar (which carries the logo) is `hidden lg:flex` — invisible on
// mobile, so mobile pages had no branding/way back to home at all. This is
// the mobile-only equivalent: a slim bar with just the logo. Deliberately
// not sticky — it scrolls away with the page content instead of staying
// pinned to the top.
export default function MobileTopBar() {
return (
<div className="lg:hidden relative bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 px-4 h-16 flex items-center flex-shrink-0">
<Link href="/" className="flex items-center hover:opacity-80 transition-opacity" aria-label="Go to home">
<Image
src="/edr-logo.png"
alt="Ethio-Djibouti Railway"
width={140}
height={48}
className="h-14 w-auto"
priority
/>
</Link>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X } from 'lucide-react';
import {
gregorianToEthiopian,
@@ -18,6 +19,7 @@ interface ModernDatePickerProps {
minDate?: Date;
maxDate?: Date;
placeholder?: string;
error?: boolean;
}
export default function ModernDatePicker({
@@ -26,6 +28,7 @@ export default function ModernDatePicker({
minDate,
maxDate,
placeholder = 'Select date',
error = false,
}: ModernDatePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMobileView, setIsMobileView] = useState(false);
@@ -259,6 +262,21 @@ export default function ModernDatePicker({
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}
</div>
{/* Mobile-only: a large, unmistakable Cancel action in addition to the header
X — the mobile view is a full-screen takeover with no backdrop to tap, so
this is the fallback way out without having to pick a date. */}
{isMobileView && (
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-900">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full py-3 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
@@ -268,7 +286,11 @@ export default function ModernDatePicker({
<button
type="button"
onClick={() => setIsOpen(true)}
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all group"
className={`w-full px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 transition-all group ${
error
? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
@@ -276,7 +298,7 @@ export default function ModernDatePicker({
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</button>
{isOpen && (
{isOpen && createPortal(
<>
{/* Mobile: full-screen takeover */}
@@ -314,7 +336,8 @@ export default function ModernDatePicker({
to { transform: scale(1); opacity: 1; }
}
`}</style>
</>
</>,
document.body
)}
</div>
);

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