mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
@@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
|
||||
@@ -804,9 +804,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.bookingType = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||
// drawdowns included — see contract-booking.service create), so the
|
||||
// one-time vs general split keys on the denormalized contract_kind:
|
||||
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
|
||||
// = everything else (ONE_TIME contracts and legacy contract-less rows).
|
||||
if (options.bookingType === 'GENERAL_CONTRACT') {
|
||||
qb.andWhere("booking.contract_kind = 'GENERAL'");
|
||||
} else {
|
||||
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
|
||||
}
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // invoiceService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
@@ -72,6 +73,8 @@ export class ContractBookingService {
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@Inject(forwardRef(() => BookingTransitionService))
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
@@ -331,6 +334,227 @@ export class ContractBookingService {
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a BARE booking instance under a GENERAL non-customs contract
|
||||
* (Path A per-booking self-clearance). One click, zero input: no schedule
|
||||
* date, no cargo, no window check, no pricing. The instance starts in the
|
||||
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
|
||||
* Operations reviews and finalizes, and only then does the customer complete
|
||||
* the booking (cargo + binding day + window check) via
|
||||
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
|
||||
*/
|
||||
async initiateUnderContract(
|
||||
contractId: string,
|
||||
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
if (!generalSelfClear) {
|
||||
throw new BadRequestException(
|
||||
'Initiate booking applies only to general import/export contracts without customs clearing.',
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.status === 'CONTRACT_CLOSED') {
|
||||
throw new BadRequestException(
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
|
||||
// Bare instance: no cargo, no date, no price. Draws no contract capacity
|
||||
// until the customer completes it after clearance.
|
||||
const booking = await insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
contractKind: contract.contractKind,
|
||||
createdByRole,
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, {}),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
cargoTotalWeightVgm: 0,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||
} as never),
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a bare initiated booking after Operations finalized its per-booking
|
||||
* clearance (CLEARANCE_READY) or returned it for changes
|
||||
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
|
||||
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
|
||||
* window + open-departure checks, pricing, consolidation and invoicing all run
|
||||
* here — the same gates a one-time shipment passes at creation.
|
||||
*/
|
||||
async completeUnderContract(
|
||||
contractId: string,
|
||||
bookingId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.contractId !== contract.id) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||
}
|
||||
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
|
||||
throw new BadRequestException(
|
||||
'Clearance must be finalized before the booking can be completed.',
|
||||
);
|
||||
}
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
const freightType = contract.freightType;
|
||||
const hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// First completion persists cargo and draws contract capacity; a resubmit
|
||||
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
||||
// the shipment day.
|
||||
if (!hasCargo) {
|
||||
await this.assertWithinQuantityCap(contract, dto);
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.assertWithinMaxCapacity(contract, dto);
|
||||
await this.assert20ftPairableAtCreate(dto);
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A zero price means no contract rate matches — roll the cargo back so
|
||||
// the instance stays CLEARANCE_READY and can be completed again once
|
||||
// the contract rates are fixed (the clearance work is not lost).
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
|
||||
// exactly like a drawdown created with cargo does. The shipment day is
|
||||
// stored first so the pairing event can resume straight into the
|
||||
// operations queue.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
||||
) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
} as never);
|
||||
const parked = await this.consolidateDrawdown(
|
||||
withContainers,
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
);
|
||||
warnings.push(parked.message);
|
||||
if (!parked.paired) {
|
||||
await this.maybeCompleteContract(contract);
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: pendingResult ?? booking, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice the now-priced booking (idempotent, non-blocking).
|
||||
await this.finalizeContractBooking(booking.id, contract, false);
|
||||
await this.maybeCompleteContract(contract);
|
||||
}
|
||||
|
||||
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
|
||||
// and the staff notification — the exact machine a one-time booking uses.
|
||||
const completed = await this.bookingTransitionService.requestOperation(
|
||||
booking.id,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
return { booking: completed, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
|
||||
@@ -799,6 +799,37 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/initiate')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
|
||||
})
|
||||
initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.contractBookingService.initiateUnderContract(
|
||||
id,
|
||||
{ contractRouteId: dto?.contractRouteId },
|
||||
{ id: user?.id ?? user?.sub },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||
})
|
||||
completeBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
) {
|
||||
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/validate-shipment')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -197,12 +197,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
// {
|
||||
// label: "Self-Clearance Review",
|
||||
// href: "/dashboard/contracts/ops-clearance",
|
||||
// icon: <ShieldCheck />,
|
||||
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
// },
|
||||
// Operations Path A queue: per-booking self-clearance review for
|
||||
// GENERAL non-customs booking instances (and legacy self-clear bookings).
|
||||
{
|
||||
label: "Self-Clearance Review",
|
||||
href: "/dashboard/contracts/ops-clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
|
||||
@@ -380,6 +380,31 @@ export function ClearanceReviewSection({
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : clearance.status !== "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
// Finalize is only valid from DOCUMENTS_UNDER_REVIEW (the API rejects
|
||||
// any other status with a 409) — once the booking moved on, show the
|
||||
// finalized state instead of a button that can only fail.
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
{clearance.allApproved ? (
|
||||
<CheckCircle2 size={15} />
|
||||
) : (
|
||||
<FileCheck2 size={15} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "Clearance has been finalized. The customer can now pick a shipment day and proceed to operation."
|
||||
: "Finalization unlocks once the customer submits their documents and every required document is approved."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
|
||||
@@ -308,6 +308,12 @@ const App = () => {
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
clearance — same form, submits to the complete endpoint. */}
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/clearance"
|
||||
element={<ContractClearanceFlow />}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Button, Group, type ButtonProps } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { api } from "@/services/api";
|
||||
import { ContractClearanceAction } from "./ContractClearanceAction";
|
||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||
|
||||
@@ -56,6 +60,18 @@ export function ContractCustomerAction({
|
||||
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
||||
}
|
||||
|
||||
if (action.type === "initiate") {
|
||||
return (
|
||||
<InitiateBookingButton
|
||||
contract={action.contract}
|
||||
label={action.label}
|
||||
icon={action.icon}
|
||||
size={size}
|
||||
listStyle={listStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = action.icon;
|
||||
const variant = action.primary ? "filled" : "light";
|
||||
|
||||
@@ -80,6 +96,88 @@ export function ContractCustomerAction({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click bare booking instance under a GENERAL non-customs contract. No
|
||||
* form, no date, no window gate — the new instance lands in per-booking
|
||||
* clearance (AWAITING_DOCUMENTS) and the customer is taken straight to it.
|
||||
*/
|
||||
export function InitiateBookingButton({
|
||||
contract,
|
||||
label = "Initiate booking",
|
||||
icon: Icon,
|
||||
size = "xs",
|
||||
listStyle = false,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
size?: ButtonProps["size"];
|
||||
listStyle?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.contracts.initiateBookingUnderContract.call({
|
||||
id: contract.id,
|
||||
// Multi-route contracts must name a route; single-route auto-selects.
|
||||
contractRouteId:
|
||||
(contract.routes?.length ?? 0) > 1
|
||||
? contract.routes![0].id
|
||||
: undefined,
|
||||
}),
|
||||
onSuccess: (booking) => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||
});
|
||||
toast.success(
|
||||
"Booking initiated — upload your clearance documents to start the review.",
|
||||
);
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
},
|
||||
onError: (e: Error) =>
|
||||
toast.error(e.message || "Could not initiate the booking"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
h={listStyle ? 34 : undefined}
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<Icon size={15} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mutation.mutate();
|
||||
}}
|
||||
styles={
|
||||
listStyle
|
||||
? {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
paddingInline: 14,
|
||||
whiteSpace: "nowrap" as const,
|
||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
fw={listStyle ? undefined : 700}
|
||||
fz={listStyle ? undefined : 13}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Action column cell: doc button + primary customer action. */
|
||||
export function ContractCustomerActionCell({
|
||||
contract,
|
||||
|
||||
@@ -72,6 +72,14 @@ export type ContractCustomerAction =
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
| {
|
||||
/** One-click bare booking instance (GENERAL non-customs) — mutation, not navigation. */
|
||||
type: "initiate";
|
||||
contract: Freight.IContract;
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
function findPayableBookingForContract(
|
||||
@@ -210,6 +218,15 @@ export function deriveContractCustomerAction(
|
||||
}
|
||||
|
||||
const bookingAction = getContractBookingAction(contract, bookings);
|
||||
if (bookingAction.kind === "initiate") {
|
||||
return {
|
||||
type: "initiate",
|
||||
contract,
|
||||
label: "Initiate booking",
|
||||
primary: true,
|
||||
icon: PackagePlus,
|
||||
};
|
||||
}
|
||||
if (bookingAction.kind === "book") {
|
||||
return {
|
||||
type: "navigate",
|
||||
|
||||
@@ -142,6 +142,9 @@ export const URL_CONSTANTS = {
|
||||
`/api/contracts/${id}/clearance/documents`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||
BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`,
|
||||
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||
`/api/contracts/${id}/bookings/${bookingId}/complete`,
|
||||
VALIDATE_SHIPMENT: (id: string) =>
|
||||
`/api/contracts/${id}/validate-shipment`,
|
||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { Alert, Button, Group } from "@mantine/core";
|
||||
import { CheckCircle2, Upload } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
|
||||
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
|
||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
/**
|
||||
* Customer-facing clearance section on the booking detail page: shows the
|
||||
* resolved document grid, lets the customer (re)upload pending/queried documents
|
||||
* plus ad-hoc named documents, and proceed to operation once Global Logistics
|
||||
* marks the booking CLEARANCE_READY.
|
||||
*
|
||||
* The flow body, calendar, and mutations are shared with the home-page action
|
||||
* modal via `useClearanceFlow` / `ClearanceFlow`.
|
||||
* Customer-facing clearance section on the booking detail page: a compact
|
||||
* status summary with a single action button. The document grid, re-uploads,
|
||||
* and the shipment-day picker all live in the shared {@link BookingActionModal}
|
||||
* (the same modal the My Shipments list uses), so the flow behaves identically
|
||||
* from both entry points.
|
||||
*/
|
||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const navigate = useNavigate();
|
||||
const flow = useClearanceFlow(booking);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const status = booking.status as string;
|
||||
const action = getBookingNextAction(booking);
|
||||
|
||||
if (flow.status === "OPERATION_REQUESTED") {
|
||||
if (status === "OPERATION_REQUESTED") {
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Operation</CardTitle>
|
||||
@@ -34,56 +33,51 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (flow.isLoading || !flow.clearance) {
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
</SectionCard>
|
||||
const summary =
|
||||
status === "CLEARANCE_READY" ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||
Clearance is complete. Pick a shipment day and proceed to operation.
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||
Your documents are being reviewed. Re-upload any queried documents to
|
||||
proceed — approved documents stay as they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
|
||||
Upload the required clearance documents so your shipment can be
|
||||
reviewed.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<BookingClearanceWorkflowBanner booking={booking} />
|
||||
<Group justify="space-between" align="center" mb="md" mt="md">
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
{action && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<ClipboardList size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<ClearanceFlow
|
||||
{summary}
|
||||
|
||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||
Use “{action?.label ?? "the action button"}” to manage your clearance
|
||||
documents.
|
||||
</Text>
|
||||
|
||||
<BookingActionModal
|
||||
booking={booking}
|
||||
flow={flow}
|
||||
footer={
|
||||
<Group justify="flex-end" mt="lg" gap="sm">
|
||||
{flow.canUpload && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => flow.submitDocuments()}
|
||||
loading={flow.uploadMutation.isPending}
|
||||
disabled={!flow.canSubmit}
|
||||
>
|
||||
Submit documents
|
||||
</Button>
|
||||
)}
|
||||
{flow.isReady && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={() =>
|
||||
flow.proceedToOperation({
|
||||
onSuccess: () => navigate(`/bookings/${booking.id}`),
|
||||
})
|
||||
}
|
||||
loading={flow.proceedMutation.isPending}
|
||||
disabled={!flow.scheduledDate}
|
||||
>
|
||||
Proceed to operation
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -16,14 +16,23 @@ export const PROGRESS_STAGES = [
|
||||
statuses: ["DRAFT", "CHANGES_REQUESTED"],
|
||||
},
|
||||
{
|
||||
// AWAITING_DOCUMENTS: a fresh contract-drawdown booking lands here — the
|
||||
// customer just submitted it and now uploads clearance documents.
|
||||
label: "Submitted",
|
||||
icon: ClipboardCheck,
|
||||
statuses: ["SUBMITTED"],
|
||||
statuses: ["SUBMITTED", "AWAITING_DOCUMENTS"],
|
||||
},
|
||||
{
|
||||
// Clearance review is the approval step for contract-drawdown bookings.
|
||||
label: "Approval",
|
||||
icon: ShieldCheck,
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
|
||||
statuses: [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"APPROVED",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Contract",
|
||||
@@ -37,6 +46,7 @@ export const PROGRESS_STAGES = [
|
||||
"FULLY_EXECUTED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"OPERATION_REQUESTED",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -234,24 +244,24 @@ export const STATUS_MAP: Record<
|
||||
title: "Clearance documents needed",
|
||||
description:
|
||||
"Upload the required clearance documents so your shipment can be reviewed.",
|
||||
stage: 5,
|
||||
stage: 1,
|
||||
},
|
||||
DOCUMENTS_UNDER_REVIEW: {
|
||||
title: "Documents under review",
|
||||
description:
|
||||
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
||||
stage: 5,
|
||||
stage: 2,
|
||||
},
|
||||
CLEARANCE_READY: {
|
||||
title: "Cleared — choose a shipment day",
|
||||
description:
|
||||
"Clearance is complete. Pick a shipment day and proceed to operation.",
|
||||
stage: 5,
|
||||
stage: 2,
|
||||
},
|
||||
OPERATION_REQUESTED: {
|
||||
title: "Operation requested",
|
||||
description: "Operation requested. An operator will take your shipment forward.",
|
||||
stage: 5,
|
||||
stage: 4,
|
||||
},
|
||||
CONTRACT_ACTIVE: {
|
||||
title: "Contract active",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, Group, Modal, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Upload } from "lucide-react";
|
||||
import { CheckCircle2, PackagePlus, Upload } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -40,6 +41,7 @@ function BookingActionModalBody({
|
||||
}) {
|
||||
const action = getBookingNextAction(booking);
|
||||
const flow = useClearanceFlow(booking);
|
||||
const navigate = useNavigate();
|
||||
const reference = booking.reference;
|
||||
|
||||
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
|
||||
@@ -91,17 +93,31 @@ function BookingActionModalBody({
|
||||
Submit documents
|
||||
</Button>
|
||||
)}
|
||||
{flow.isReady && (
|
||||
{flow.needsCompletion && flow.completeTo ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={handleProceed}
|
||||
loading={flow.proceedMutation.isPending}
|
||||
disabled={!flow.scheduledDate}
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(flow.completeTo!);
|
||||
}}
|
||||
>
|
||||
Proceed to operation
|
||||
Complete booking
|
||||
</Button>
|
||||
) : (
|
||||
flow.isReady && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={handleProceed}
|
||||
loading={flow.proceedMutation.isPending}
|
||||
disabled={!flow.scheduledDate}
|
||||
>
|
||||
Proceed to operation
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
customerDocs,
|
||||
glDocs,
|
||||
isReady,
|
||||
needsCompletion,
|
||||
canUpload,
|
||||
isInitialUpload,
|
||||
status,
|
||||
@@ -78,9 +79,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
<Stack gap={0}>
|
||||
{isReady ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||
{clearance.includesCustoms
|
||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
||||
: "Clearance is ready. You can now proceed to operation."}
|
||||
{needsCompletion
|
||||
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
|
||||
: clearance.includesCustoms
|
||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
||||
: "Clearance is ready. You can now proceed to operation."}
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||
@@ -208,7 +211,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
{isReady && !needsCompletion && (
|
||||
<Box mt="lg">
|
||||
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
||||
Choose your shipment day
|
||||
|
||||
@@ -67,6 +67,16 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
);
|
||||
|
||||
const isReady = status === "CLEARANCE_READY";
|
||||
// Bare initiated instance (GENERAL non-customs "Initiate booking"): created
|
||||
// with no cargo and no price. Once ready it is COMPLETED on the full booking
|
||||
// form (cargo + shipment day + window check), not date-only proceed.
|
||||
const needsCompletion =
|
||||
isReady &&
|
||||
Boolean(booking.contractId) &&
|
||||
!(Number(booking.totalAmount ?? 0) > 0);
|
||||
const completeTo = needsCompletion
|
||||
? `/contracts/${booking.contractId}/bookings/${booking.id}/complete`
|
||||
: null;
|
||||
const canUpload =
|
||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||
// The very first upload (nothing in review yet). Here every required document
|
||||
@@ -146,6 +156,8 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
customerDocs,
|
||||
glDocs,
|
||||
isReady,
|
||||
needsCompletion,
|
||||
completeTo,
|
||||
canUpload,
|
||||
isInitialUpload,
|
||||
// staged upload state
|
||||
|
||||
@@ -61,6 +61,7 @@ import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { getContractBookingAction } from "./contract-booking-action";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
@@ -348,6 +349,9 @@ export default function ContractDetailPage() {
|
||||
const canBookShipment =
|
||||
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
||||
const canRequestShipment = bookingAction.kind === "request";
|
||||
// GENERAL non-customs import/export: one-click bare booking instance — the
|
||||
// per-booking clearance runs first, so no window gate applies here.
|
||||
const canInitiateBooking = bookingAction.kind === "initiate";
|
||||
// Customs + clearance finalized: GL is preparing the booking — surface a
|
||||
// status notice instead of any action.
|
||||
const glPreparingBooking = customsPath && clearanceFinalized;
|
||||
@@ -438,6 +442,13 @@ export default function ContractDetailPage() {
|
||||
Request shipment
|
||||
</Button>
|
||||
)}
|
||||
{canInitiateBooking && (
|
||||
<InitiateBookingButton
|
||||
contract={contract}
|
||||
icon={PackagePlus}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canBookShipment &&
|
||||
(bookingWindowOpen ? (
|
||||
<Button
|
||||
@@ -1289,6 +1300,13 @@ export default function ContractDetailPage() {
|
||||
>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<SectionLabel>Bookings under this contract</SectionLabel>
|
||||
{canInitiateBooking && (
|
||||
<InitiateBookingButton
|
||||
contract={contract}
|
||||
icon={PackagePlus}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
{canBookShipment && bookingWindowOpen && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -77,7 +77,12 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
};
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
// With `bookingId` the page runs in COMPLETION mode: the bare booking
|
||||
// instance (created by "Initiate booking") already passed per-booking
|
||||
// clearance, and this form supplies the deferred cargo + shipment day. Same
|
||||
// window gates, same validation, same price confirmation — the submit just
|
||||
// completes the existing booking instead of creating a new one.
|
||||
const { id, bookingId } = useParams<{ id: string; bookingId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: contract, isLoading } = useQuery(
|
||||
@@ -204,7 +209,13 @@ export default function NewShipmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
|
||||
return (
|
||||
<NewShipmentBookingForm
|
||||
contract={contract}
|
||||
contractId={id!}
|
||||
completeBookingId={bookingId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
@@ -219,9 +230,12 @@ function bulkUnitOfMeasure(
|
||||
function NewShipmentBookingForm({
|
||||
contract,
|
||||
contractId,
|
||||
completeBookingId,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
contractId: string;
|
||||
/** Set when completing an initiated (bare) booking after clearance. */
|
||||
completeBookingId?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -251,7 +265,13 @@ function NewShipmentBookingForm({
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||
completeBookingId
|
||||
? api.contracts.completeBookingUnderContract.call({
|
||||
id: contractId,
|
||||
bookingId: completeBookingId,
|
||||
dto,
|
||||
})
|
||||
: api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||
onSuccess: (booking) => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
@@ -368,10 +388,12 @@ function NewShipmentBookingForm({
|
||||
>
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
New Shipment Booking
|
||||
{completeBookingId ? "Complete Your Booking" : "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Book a shipment against contract {contract.reference}.
|
||||
{completeBookingId
|
||||
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
: `Book a shipment against contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -398,7 +420,9 @@ function NewShipmentBookingForm({
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to create the shipment booking
|
||||
{completeBookingId
|
||||
? "Failed to complete the booking"
|
||||
: "Failed to create the shipment booking"}
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{submitMutation.error instanceof Error
|
||||
|
||||
@@ -15,7 +15,12 @@ export const TERMINAL_BOOKING_STATUSES = [
|
||||
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||
|
||||
export type ContractBookingActionKind = "book" | "rebook" | "request" | "none";
|
||||
export type ContractBookingActionKind =
|
||||
| "book"
|
||||
| "rebook"
|
||||
| "request"
|
||||
| "initiate"
|
||||
| "none";
|
||||
|
||||
export interface ContractBookingAction {
|
||||
kind: ContractBookingActionKind;
|
||||
@@ -63,5 +68,13 @@ export function getContractBookingAction(
|
||||
return { kind: hasExpired ? "rebook" : "book", to };
|
||||
}
|
||||
|
||||
// GENERAL non-customs import/export: one-click bare booking instance — the
|
||||
// per-booking clearance runs first, cargo + shipment day come at completion.
|
||||
// No window gate here; the window is checked when the booking is completed.
|
||||
// DOMESTIC (intercity) keeps the direct booking form.
|
||||
if (contract.tradeDirection !== "DOMESTIC") {
|
||||
return { kind: "initiate", to: `/contracts/${contract.id}` };
|
||||
}
|
||||
|
||||
return { kind: "book", to };
|
||||
}
|
||||
|
||||
@@ -531,6 +531,24 @@ export const api = {
|
||||
contractsService.createBookingUnderContract(id, dto),
|
||||
),
|
||||
|
||||
initiateBookingUnderContract: endpoint<
|
||||
{ id: string; contractRouteId?: string },
|
||||
Freight.IBooking
|
||||
>("contracts", "initiateBookingUnderContract", ({ id, contractRouteId }) =>
|
||||
contractsService.initiateBookingUnderContract(id, contractRouteId),
|
||||
),
|
||||
|
||||
completeBookingUnderContract: endpoint<
|
||||
{
|
||||
id: string;
|
||||
bookingId: string;
|
||||
dto: Freight.CreateBookingUnderContractDto;
|
||||
},
|
||||
Freight.IBooking
|
||||
>("contracts", "completeBookingUnderContract", ({ id, bookingId, dto }) =>
|
||||
contractsService.completeBookingUnderContract(id, bookingId, dto),
|
||||
),
|
||||
|
||||
validateShipment: endpoint<
|
||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||
ShipmentValidation
|
||||
|
||||
@@ -329,6 +329,32 @@ export const contractsService = {
|
||||
return data.data.booking ?? data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* One-click bare booking instance under a GENERAL non-customs contract — no
|
||||
* cargo, no date. The instance enters per-booking clearance; the customer
|
||||
* completes it (cargo + shipment day) once Operations finalizes.
|
||||
*/
|
||||
initiateBookingUnderContract: async (
|
||||
id: string,
|
||||
contractRouteId?: string,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(
|
||||
C.BOOKINGS_INITIATE(id),
|
||||
contractRouteId ? { contractRouteId } : {},
|
||||
);
|
||||
return data.data.booking ?? data.data;
|
||||
},
|
||||
|
||||
/** Complete an initiated booking after clearance — same DTO as create. */
|
||||
completeBookingUnderContract: async (
|
||||
id: string,
|
||||
bookingId: string,
|
||||
dto: Freight.CreateBookingUnderContractDto,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(C.BOOKINGS_COMPLETE(id, bookingId), dto);
|
||||
return data.data.booking ?? data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pre-submit validation of a shipment booking (same DTO as
|
||||
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Agents')
|
||||
@Controller('agents')
|
||||
@@ -36,6 +37,8 @@ export class AgentsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete agent profile' })
|
||||
deleteAgent(@Param('id') id: string) {
|
||||
return this.service.deleteAgent(id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GuestBookingService } from './guest-booking.service';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Booking')
|
||||
@Controller('bookings')
|
||||
@@ -466,7 +467,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
description: 'Permanently deletes a booking record'
|
||||
})
|
||||
|
||||
@@ -1460,7 +1460,7 @@ export class BookingsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: { take: 1 },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
@@ -1518,7 +1518,7 @@ export class BookingsService {
|
||||
payment: (pkgBooking as any).paymentIntent
|
||||
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
|
||||
: undefined,
|
||||
ticket: undefined,
|
||||
tickets: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1557,7 +1557,15 @@ export class BookingsService {
|
||||
},
|
||||
})),
|
||||
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
|
||||
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
|
||||
// One ticket per passenger — matched on the frontend by passengerName, not array
|
||||
// position, since tickets are grouped/created independently of the passengers array.
|
||||
tickets: (booking as any).tickets?.map((t: any) => ({
|
||||
id: t.id,
|
||||
passengerName: t.passengerName,
|
||||
qrPayload: t.qrPayload,
|
||||
barcodePayload: t.barcodePayload,
|
||||
status: t.status,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class CurrenciesService {
|
||||
async getAllCurrencies() {
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
distinct: ['toCurrency'],
|
||||
orderBy: { toCurrency: 'asc' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const base = {
|
||||
@@ -83,12 +83,14 @@ export class CurrenciesService {
|
||||
throw new BadRequestException('Exchange rate must be positive');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.currencyExchangeRate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
rate: dto.exchangeRate,
|
||||
},
|
||||
});
|
||||
// Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up
|
||||
const updated = await this.currencyService.upsertRate(
|
||||
existing.fromCurrency,
|
||||
existing.toCurrency,
|
||||
dto.exchangeRate ?? Number(existing.rate),
|
||||
undefined,
|
||||
'MANUAL',
|
||||
);
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
@@ -112,8 +114,9 @@ export class CurrenciesService {
|
||||
throw new NotFoundException('Currency not found');
|
||||
}
|
||||
|
||||
await this.prisma.currencyExchangeRate.delete({
|
||||
where: { id },
|
||||
// Delete all records for this currency pair so no stale rates remain
|
||||
await this.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
|
||||
});
|
||||
|
||||
return { message: 'Currency deleted successfully' };
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InitiateExcessPaymentDto,
|
||||
} from './excess-baggage.dto';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
class UpsertBaggageAllowanceDto {
|
||||
@IsString() seatClassId: string;
|
||||
@@ -70,6 +71,8 @@ export class ExcessBaggageAgentController {
|
||||
}
|
||||
|
||||
@Delete('allowances/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete baggage allowance rule' })
|
||||
deleteAllowance(@Param('id') id: string) {
|
||||
return this.service.deleteAllowance(id);
|
||||
@@ -94,6 +97,8 @@ export class ExcessBaggageAgentController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
||||
deleteCharge(@Param('id') id: string) {
|
||||
return this.service.deleteCharge(id);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { UpsertExchangeRateDto } from './currency.dto';
|
||||
import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
class UpdateExchangeRateDto {
|
||||
@ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
|
||||
@@ -38,6 +39,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete an exchange rate record by ID' })
|
||||
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Rate deleted' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@Controller('fleet')
|
||||
@@ -38,6 +39,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('coach-types/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a coach type' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach type deleted' })
|
||||
@@ -74,6 +77,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('classes/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a class' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@@ -111,6 +116,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('seat-classes/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@@ -147,6 +154,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('trains/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@@ -310,6 +319,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('coaches/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@@ -329,6 +340,8 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('assignments/:id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Packages')
|
||||
@Controller('packages')
|
||||
@@ -41,7 +42,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete('inquiries/:id')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
||||
deleteInquiry(@Param('id') id: string) {
|
||||
@@ -139,7 +140,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete package (admin)' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
|
||||
@@ -180,7 +181,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete('tiers/:tierId')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete price tier (admin)' })
|
||||
deleteTier(@Param('tierId') tierId: string) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -503,7 +504,8 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Delete passenger (admin only)',
|
||||
description: 'Permanently deletes a passenger record and associated data'
|
||||
|
||||
@@ -99,6 +99,8 @@ export class PaymentsService {
|
||||
priceTierId: true,
|
||||
adultCount: true,
|
||||
childCount: true,
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
},
|
||||
@@ -129,7 +131,7 @@ export class PaymentsService {
|
||||
id: item.id,
|
||||
reference: item.id.substring(0, 8),
|
||||
bookingId: item.bookingId,
|
||||
booking: { bookingRef: b?.bookingRef },
|
||||
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
|
||||
amountMinor,
|
||||
currency: item.currency,
|
||||
method: item.method,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PromosService } from './promos.service';
|
||||
import { CreatePromotionDto } from './promos.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Promotions')
|
||||
@Controller('promos')
|
||||
@@ -64,8 +65,8 @@ export class PromosController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete promo (admin)' })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.service.delete(id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse }
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@Controller('routes')
|
||||
@@ -48,7 +49,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@@ -75,7 +77,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
|
||||
|
||||
@Delete(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' })
|
||||
@@ -119,7 +122,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
}
|
||||
|
||||
@Delete(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Template cleared' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedules')
|
||||
@@ -56,7 +57,8 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Delete('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule deleted' })
|
||||
@@ -80,7 +82,8 @@ export class SchedulesController {
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||
@@ -110,7 +113,8 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
|
||||
@@ -203,7 +207,8 @@ export class SchedulesController {
|
||||
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
||||
|
||||
@Delete(':id/coaches/:coachId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
|
||||
import { SeatClassesService } from './seat-classes.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Seat Classes')
|
||||
@Controller('seat-classes')
|
||||
@@ -42,7 +43,8 @@ export class SeatClassesController {
|
||||
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a seat class' })
|
||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat class deleted' })
|
||||
|
||||
@@ -29,6 +29,11 @@ export class CreateSeatClassDto {
|
||||
@IsInt()
|
||||
basePrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
insuranceFeeMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -837,13 +837,16 @@ export class SeatsService {
|
||||
async removeSeat(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
|
||||
if (!seat.seatNumber || seat.seatNumber.startsWith('-')) throw new BadRequestException('Seat already removed');
|
||||
|
||||
// Mark as removed, then renumber all active seats in the coach
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: `-${seat.seatNumber}` },
|
||||
});
|
||||
|
||||
await this.renumberCoachSeats(seat.coachId);
|
||||
|
||||
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||
}
|
||||
|
||||
@@ -854,10 +857,38 @@ export class SeatsService {
|
||||
throw new BadRequestException('Seat is not removed');
|
||||
}
|
||||
|
||||
const originalNumber = seat.seatNumber.slice(1);
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
|
||||
// Restore with a temporary placeholder number, then renumber
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `__restore__${seatId}` } });
|
||||
await this.renumberCoachSeats(seat.coachId);
|
||||
|
||||
return { restored: true, seatId, seatNumber: originalNumber };
|
||||
const restored = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
return { restored: true, seatId, seatNumber: restored?.seatNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renumbers all active (non-removed) seats in a coach sequentially starting from 1,
|
||||
* ordered by row then col. Removed seats (prefixed with "-") keep their slot but
|
||||
* are excluded from the numbering sequence so numbers remain continuous.
|
||||
*/
|
||||
private async renumberCoachSeats(coachId: string): Promise<void> {
|
||||
const allSeats = await this.prisma.seat.findMany({
|
||||
where: { coachId },
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
select: { id: true, seatNumber: true },
|
||||
});
|
||||
|
||||
const activeSeats = allSeats.filter(
|
||||
(s) => s.seatNumber && !s.seatNumber.startsWith('-') && !s.seatNumber.startsWith('__restore__'),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
activeSeats.map((s, idx) =>
|
||||
this.prisma.seat.update({
|
||||
where: { id: s.id },
|
||||
data: { seatNumber: String(idx + 1) },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
|
||||
import { StationsService } from './stations.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Stations')
|
||||
@Controller('stations')
|
||||
@@ -133,8 +134,8 @@ export class StationsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete station' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
export const CONFIG_KEYS = {
|
||||
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
|
||||
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
|
||||
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure',
|
||||
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
|
||||
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
|
||||
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
|
||||
@@ -15,6 +16,7 @@ export const CONFIG_KEYS = {
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
|
||||
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
|
||||
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
|
||||
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
|
||||
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
|
||||
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@Controller('tickets')
|
||||
@@ -9,7 +10,8 @@ export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@SetMetadata('isPublic', true)
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
|
||||
@@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
imports: [NotificationsModule, SystemConfigModule],
|
||||
controllers: [TicketsController],
|
||||
providers: [TicketsService, JwtGuard],
|
||||
exports: [TicketsService, JwtGuard],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
interface OfflineValidation {
|
||||
@@ -20,6 +21,7 @@ export class TicketsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -423,26 +425,20 @@ export class TicketsService {
|
||||
|
||||
// Check if ticket date matches today
|
||||
const today = new Date();
|
||||
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureDate = new Date((booking as any).schedule.departureAt);
|
||||
const departureDateStr = departureDate.toISOString().split('T')[0];
|
||||
|
||||
// Check if ticket is for today
|
||||
if (departureDateStr !== todayDateStr) {
|
||||
if (departureDateStr < todayDateStr) {
|
||||
throw new BadRequestException('Ticket has expired - departure date has passed');
|
||||
} else {
|
||||
throw new BadRequestException('Ticket is for a future date - cannot board early');
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: ticket expires 4 hours after departure time
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
|
||||
if (today > expiryTime) {
|
||||
throw new BadRequestException('Ticket has expired - boarding window closed');
|
||||
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
|
||||
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
|
||||
|
||||
if (today < boardingOpenTime) {
|
||||
throw new BadRequestException(
|
||||
`Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (today >= departureTime) {
|
||||
throw new BadRequestException('Boarding is closed — departure time has passed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,12 +254,16 @@ function BookingsPageContent() {
|
||||
},
|
||||
{
|
||||
key: 'contact', label: 'Primary contact',
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
|
||||
</div>
|
||||
),
|
||||
render: (booking: any) => {
|
||||
const phone = booking.contactPhone || booking.passenger?.phone || '—';
|
||||
const email = booking.contactEmail || booking.passenger?.email || '—';
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">{phone}</div>
|
||||
<div className="text-sm text-muted-foreground truncate" title={email}>{email}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus', label: 'Payment',
|
||||
|
||||
@@ -146,7 +146,13 @@ export default function CurrenciesPage() {
|
||||
|
||||
const actions = [
|
||||
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
|
||||
{ label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 },
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (c: CurrencyRate) => setDeleteConfirm(c),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
show: (c: CurrencyRate) => c.id !== 'etb-base',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function PaymentsPage() {
|
||||
switch (key) {
|
||||
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
|
||||
case 'booking': return payment.booking?.bookingRef || 'N/A';
|
||||
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
|
||||
case 'amount': return formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB');
|
||||
case 'method': return payment.method || '';
|
||||
case 'status': return payment.status || '';
|
||||
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
|
||||
@@ -116,7 +116,7 @@ export default function PaymentsPage() {
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
|
||||
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') },
|
||||
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
@@ -204,7 +204,7 @@ export default function PaymentsPage() {
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
|
||||
{ label: 'Amount', value: formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB') },
|
||||
{ label: 'Method', value: p.method || '—' },
|
||||
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
|
||||
].map(({ label, value }) => (
|
||||
@@ -222,7 +222,7 @@ export default function PaymentsPage() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
|
||||
<p className="text-xs text-muted-foreground mb-1">Amount</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB')}</p>
|
||||
</div>
|
||||
<Field label="Method" value={p.method} />
|
||||
<Field label="Status" value={p.status} />
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
|
||||
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
|
||||
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
|
||||
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
|
||||
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
|
||||
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
|
||||
@@ -24,6 +25,7 @@ export default function SettingsPage() {
|
||||
.then((data) => {
|
||||
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
|
||||
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
|
||||
if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
|
||||
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
|
||||
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
|
||||
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
|
||||
@@ -39,6 +41,7 @@ export default function SettingsPage() {
|
||||
await systemConfigApi.update({
|
||||
seat_hold_duration_minutes: seatHoldMinutes,
|
||||
hold_cutoff_hours_before_departure: holdCutoffHours,
|
||||
boarding_window_hours_before_departure: boardingWindowHours,
|
||||
throttle_auth_limit: throttleAuthLimit,
|
||||
throttle_strict_limit: throttleStrictLimit,
|
||||
throttle_default_limit: throttleDefaultLimit,
|
||||
@@ -184,6 +187,23 @@ export default function SettingsPage() {
|
||||
Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<label className="label" htmlFor="boarding-window">
|
||||
Boarding Window Before Departure (hours)
|
||||
</label>
|
||||
<input
|
||||
id="boarding-window"
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
className="input"
|
||||
value={boardingWindowHours}
|
||||
onChange={(e) => setBoardingWindowHours(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Boarding opens this many hours before departure and closes exactly at departure time. Default: 4 hours.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -406,6 +406,7 @@ export default function TariffRatesPage() {
|
||||
name="insuranceFeeMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||||
key={editingClass?.id ?? 'new-insurance'}
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 25.00"
|
||||
|
||||
@@ -90,21 +90,21 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Financial',
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
// { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
|
||||
{ name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
// { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
// { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
|
||||
// { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Customer Services',
|
||||
items: [
|
||||
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
|
||||
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
|
||||
// { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
|
||||
// { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
|
||||
]
|
||||
},
|
||||
@@ -126,7 +126,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
|
||||
// { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
|
||||
{ name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
|
||||
|
||||
@@ -19,10 +19,13 @@ type BookingWithTicket = {
|
||||
totalMinor?: number;
|
||||
createdAt?: string;
|
||||
paymentMethod?: string;
|
||||
ticket?: {
|
||||
// One ticket per passenger — match by passengerName, not array position (see
|
||||
// bookings.service.ts's getByRef).
|
||||
tickets?: Array<{
|
||||
passengerName?: string;
|
||||
barcodePayload?: string;
|
||||
qrPayload?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default function ConfirmationPage() {
|
||||
@@ -36,6 +39,14 @@ export default function ConfirmationPage() {
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
const confirmAttempted = useRef(false);
|
||||
|
||||
// Warms the code-split voucher module ahead of the click so the handler's own
|
||||
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
|
||||
// too long after the originating click's synchronous execution window is silently
|
||||
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
||||
useEffect(() => {
|
||||
import('@/lib/generate-voucher');
|
||||
}, []);
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
@@ -142,9 +153,16 @@ export default function ConfirmationPage() {
|
||||
seatClass: inboundSchedule.selectedSeatClassName,
|
||||
} : undefined;
|
||||
|
||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
|
||||
// between them — a setTimeout delay would push later saves outside the click's
|
||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
||||
for (let i = 0; i < passengers.length; i++) {
|
||||
const p = passengers[i];
|
||||
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
|
||||
// Same match-by-name-then-position as the on-screen ticket list above — no
|
||||
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
|
||||
const matchedTicket =
|
||||
_booking?.tickets?.find((t) => t.passengerName === p.name) ?? _booking?.tickets?.[i] ?? null;
|
||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: pnr,
|
||||
@@ -163,9 +181,6 @@ export default function ConfirmationPage() {
|
||||
currency: voucherCurrency,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// brief pause between downloads so browsers don't block them
|
||||
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -390,10 +405,15 @@ export default function ConfirmationPage() {
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
|
||||
<div className="space-y-4">
|
||||
{passengers.map((passenger, index) => {
|
||||
const backendTicket = _booking?.ticket || null;
|
||||
const ticketNumber = isConfirmed
|
||||
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
|
||||
: null;
|
||||
// Match by name first (tickets aren't necessarily created/ordered the same
|
||||
// way as this passengers array) — fall back to position if no name match.
|
||||
const backendTicket =
|
||||
_booking?.tickets?.find((t) => t.passengerName === passenger.name) ??
|
||||
_booking?.tickets?.[index] ??
|
||||
null;
|
||||
// No fabricated placeholder — a made-up TKT-... number reads as real and is
|
||||
// misleading if it doesn't match what's actually on file.
|
||||
const ticketNumber = isConfirmed ? backendTicket?.barcodePayload || null : null;
|
||||
|
||||
return (
|
||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
||||
@@ -414,7 +434,9 @@ export default function ConfirmationPage() {
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Share2,
|
||||
Copy,
|
||||
Check,
|
||||
CreditCard,
|
||||
@@ -49,6 +48,14 @@ function BookingDetailContent() {
|
||||
const [copiedPNR, setCopiedPNR] = useState(false);
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
|
||||
// Warms the code-split voucher module ahead of the click so the handler's own
|
||||
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
|
||||
// too long after the originating click's synchronous execution window is silently
|
||||
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
||||
useEffect(() => {
|
||||
import('@/lib/generate-voucher');
|
||||
}, []);
|
||||
|
||||
const { data: booking, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['booking-detail', bookingRef],
|
||||
queryFn: async () => {
|
||||
@@ -629,7 +636,7 @@ function BookingDetailContent() {
|
||||
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
||||
{isConfirmed && (
|
||||
<>
|
||||
<button
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
@@ -646,14 +653,6 @@ function BookingDetailContent() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn-secondary flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Download Tickets
|
||||
</button>
|
||||
<button className="btn-secondary flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,36 @@ function clearPendingFaydaIndex() {
|
||||
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY);
|
||||
}
|
||||
|
||||
// Verification is a full-page redirect out to Fayda and back (same flow on desktop and mobile —
|
||||
// no popup). The in-progress form only lives in React memory, which the reload wipes, so we
|
||||
// snapshot it to sessionStorage before leaving and restore it (in the form's defaultValues) on
|
||||
// return. sessionStorage survives a same-tab navigation, including the cross-origin round trip.
|
||||
const FAYDA_FORM_SNAPSHOT_KEY = 'edr_fayda_form_snapshot';
|
||||
|
||||
function saveFaydaFormSnapshot(snapshot: unknown) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.sessionStorage.setItem(FAYDA_FORM_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// sessionStorage full/unavailable — verification still works, only unsaved fields are lost.
|
||||
}
|
||||
}
|
||||
|
||||
function getFaydaFormSnapshot(): { passengers?: any[]; createAccount?: boolean } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(FAYDA_FORM_SNAPSHOT_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFaydaFormSnapshot() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.removeItem(FAYDA_FORM_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
// Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
|
||||
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
|
||||
const g = String(raw || '').trim().toUpperCase();
|
||||
@@ -85,11 +115,13 @@ function DobPickerModal({
|
||||
onChange,
|
||||
error,
|
||||
passengerType = 'ADULT',
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
error?: string;
|
||||
passengerType?: 'ADULT' | 'CHILD';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
@@ -286,9 +318,10 @@ function DobPickerModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
className={`input-field w-full text-left flex items-center justify-between ${
|
||||
error ? 'border-red-500' : ''
|
||||
}`}
|
||||
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
|
||||
{displayValue || 'Select date of birth'}
|
||||
@@ -470,12 +503,14 @@ function PhoneInput({
|
||||
onInterimChange,
|
||||
onNormalized,
|
||||
error,
|
||||
disabled = false,
|
||||
}: {
|
||||
nationality: string;
|
||||
storedValue: string;
|
||||
onInterimChange: (full: string) => void;
|
||||
onNormalized: (full: string) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const nat = getPhoneNat(nationality);
|
||||
const preset = PHONE_PRESETS[nat];
|
||||
@@ -519,7 +554,10 @@ function PhoneInput({
|
||||
onBlur={handleBlur}
|
||||
placeholder={preset.example}
|
||||
autoComplete="tel"
|
||||
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
|
||||
readOnly={disabled}
|
||||
className={`flex-1 px-3 py-2.5 text-sm text-gray-900 dark:text-white outline-none min-w-0 ${
|
||||
disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
@@ -565,6 +603,10 @@ const passengerSchema = z.object({
|
||||
passportIssuingAuthority: z.string().optional(),
|
||||
faydaVerified: z.boolean().optional(),
|
||||
faydaSub: z.string().optional(),
|
||||
// Set when the corresponding contact value was supplied by Fayda (vs typed by the user) —
|
||||
// a Fayda-supplied phone/email is locked; a field Fayda left blank stays editable.
|
||||
faydaEmailLocked: z.boolean().optional(),
|
||||
faydaPhoneLocked: z.boolean().optional(),
|
||||
formExpanded: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||
@@ -635,7 +677,7 @@ function createFormSchema(adultCount: number) {
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
export default function PassengersPage() {
|
||||
function PassengersForm() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
@@ -662,6 +704,11 @@ export default function PassengersPage() {
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
passengers: Array.from({ length: totalPassengers }, (_, i) => {
|
||||
// Returning from a Fayda redirect: restore the exact in-progress form we snapshotted
|
||||
// before leaving, so no passenger's typed data is lost. The completion effect then
|
||||
// applies the verified attributes on top for the passenger who initiated it.
|
||||
const snap = getFaydaFormSnapshot()?.passengers?.[i];
|
||||
if (snap) return snap;
|
||||
const stored = storedPassengers[i];
|
||||
if (stored?.name) {
|
||||
return {
|
||||
@@ -700,7 +747,7 @@ export default function PassengersPage() {
|
||||
formExpanded: i >= adultCount,
|
||||
};
|
||||
}),
|
||||
createAccount: false,
|
||||
createAccount: getFaydaFormSnapshot()?.createAccount ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -748,6 +795,26 @@ export default function PassengersPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The redirect snapshot is consumed once, during the form's defaultValues at mount. Clear it
|
||||
// afterwards so a later visit to this page doesn't restore stale data.
|
||||
useEffect(() => {
|
||||
clearFaydaFormSnapshot();
|
||||
}, []);
|
||||
|
||||
// Show the green "verified" banner for any passenger restored from a snapshot as already
|
||||
// Fayda-verified (verificationStatus is React state and doesn't survive the redirect).
|
||||
useEffect(() => {
|
||||
if (!formInitialized) return;
|
||||
setVerificationStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
passengers.forEach((p, i) => {
|
||||
if ((p as any)?.faydaVerified && !next[i]) next[i] = 'success';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formInitialized]);
|
||||
|
||||
// Complete Fayda verification once the form is ready and callback params are present.
|
||||
// This effect runs whenever this route reloads with ?code&state — which happens either
|
||||
// inside the verification popup, or, if the browser refused to open a popup, as a full
|
||||
@@ -764,8 +831,10 @@ export default function PassengersPage() {
|
||||
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
|
||||
);
|
||||
|
||||
if (response?.success && response?.data?.verified) {
|
||||
const d = response.data;
|
||||
// apiClient already unwraps the { success, data } envelope, so `response` is the
|
||||
// verification result itself.
|
||||
const d = response;
|
||||
if (d?.verified) {
|
||||
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
|
||||
|
||||
// A single Fayda identity can't be reused across two different passengers.
|
||||
@@ -786,12 +855,22 @@ export default function PassengersPage() {
|
||||
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
|
||||
// Only fill in this passenger's own contact fields if they haven't entered them yet.
|
||||
if (d.email && !watch(`passengers.${targetIndex}.email`)) setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
|
||||
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
|
||||
if (d.email && !watch(`passengers.${targetIndex}.email`)) {
|
||||
setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
|
||||
setValue(`passengers.${targetIndex}.faydaEmailLocked`, true);
|
||||
}
|
||||
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) {
|
||||
setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
|
||||
setValue(`passengers.${targetIndex}.faydaPhoneLocked`, true);
|
||||
}
|
||||
setValue(`passengers.${targetIndex}.faydaVerified`, true);
|
||||
setValue(`passengers.${targetIndex}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
|
||||
|
||||
if (targetIndex === 0 && isAuthenticated) {
|
||||
updateUser({ fullName: d.fullName, faydaVerified: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
@@ -824,6 +903,15 @@ export default function PassengersPage() {
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isInitialized) return;
|
||||
// Returning from a Fayda redirect (?code&state): the snapshot restore + completion effect
|
||||
// own the form here — don't overwrite passenger 0 with the profile fetch.
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('code') && params.get('state')) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
@@ -864,16 +952,19 @@ export default function PassengersPage() {
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Only one passenger can verify at a time — this keeps the status poll below
|
||||
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
|
||||
// Only one passenger can verify at a time so the returning ?code&state is unambiguously
|
||||
// applied to the passenger who started it.
|
||||
if (verifyingIndex !== null) return;
|
||||
|
||||
setVerifyingIndex(index);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
// Persist which passenger this is for so it survives a full-page redirect/reload
|
||||
// if the browser can't open a popup (e.g. some mobile browsers).
|
||||
// Stash the verifying passenger index and a full snapshot of the in-progress form. Both live
|
||||
// in sessionStorage, which survives the same-tab round trip out to Fayda and back — so no
|
||||
// typed data is lost and the callback is applied to the right passenger. Identical flow on
|
||||
// desktop and mobile: a full-page redirect, no popup and no window.opener dependency.
|
||||
setPendingFaydaIndex(index);
|
||||
saveFaydaFormSnapshot({ passengers: watch('passengers'), createAccount: watch('createAccount') });
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
@@ -881,78 +972,16 @@ export default function PassengersPage() {
|
||||
platform: 'WEB',
|
||||
saveToAccount: index === 0 && isAuthenticated,
|
||||
});
|
||||
|
||||
const authorizationUrl = response.authorizationUrl;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
'FaydaVerification',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Unable to open the Fayda verification window. Please allow pop-ups and try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkPopup = setInterval(async () => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkPopup);
|
||||
try {
|
||||
const statusResponse: any = await apiClient.get('/fayda/verification/status');
|
||||
if (statusResponse.verified) {
|
||||
const faydaSub: string | undefined = statusResponse.sub || statusResponse.faydaSub || statusResponse.fin;
|
||||
const usedByOther = faydaSub && passengers.some(
|
||||
(p, i) => i !== index && (p as any).faydaSub === faydaSub,
|
||||
);
|
||||
|
||||
if (usedByOther) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'This Fayda identity is already linked to another passenger on this booking.' }));
|
||||
} else {
|
||||
setValue(`passengers.${index}.name`, statusResponse.fullName || '', { shouldValidate: true });
|
||||
if (statusResponse.dateOfBirth) setValue(`passengers.${index}.dateOfBirth`, statusResponse.dateOfBirth, { shouldValidate: true });
|
||||
const normalizedGender = normalizeFaydaGender(statusResponse.gender);
|
||||
if (normalizedGender) setValue(`passengers.${index}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${index}.faydaSub`, faydaSub);
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
|
||||
if (index === 0 && isAuthenticated) {
|
||||
updateUser({
|
||||
fullName: statusResponse.fullName,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: statusResponse.verifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again.' }));
|
||||
} finally {
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
// Redirect the whole tab to eSignet. Fayda returns the browser to this same route
|
||||
// (FAYDA_WEB_REDIRECT_URI = <portal>/booking/passengers) with ?code&state, which the
|
||||
// completion effect above picks up on mount.
|
||||
window.location.href = response.authorizationUrl;
|
||||
} catch (error) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
clearFaydaFormSnapshot();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1025,12 +1054,8 @@ export default function PassengersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchCriteria) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [searchCriteria, router]);
|
||||
|
||||
// searchCriteria is guaranteed present here — the PassengersPage gate below only mounts this
|
||||
// component after the persisted booking store has rehydrated and confirmed a booking exists.
|
||||
if (!searchCriteria) return null;
|
||||
|
||||
if (!formInitialized || faydaCompleting) {
|
||||
@@ -1070,6 +1095,13 @@ export default function PassengersPage() {
|
||||
const isVerifyingThis = verifyingIndex === index;
|
||||
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
|
||||
const faydaError = faydaErrors[index];
|
||||
// Identity fields sourced from a completed Fayda verification are locked — the
|
||||
// passenger can't edit the verified name / date of birth / gender.
|
||||
const isFaydaLocked = !!passengers[index]?.faydaVerified;
|
||||
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
|
||||
// blank stays editable so the passenger can add their own phone/email.
|
||||
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked;
|
||||
const isEmailLocked = !!passengers[index]?.faydaEmailLocked;
|
||||
|
||||
return (
|
||||
<div key={field.id} className="card">
|
||||
@@ -1148,7 +1180,7 @@ export default function PassengersPage() {
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda — details auto-filled below
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda — verified details are locked below
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1165,7 +1197,8 @@ export default function PassengersPage() {
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
readOnly={isFaydaLocked}
|
||||
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
@@ -1181,20 +1214,29 @@ export default function PassengersPage() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{isFaydaLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
@@ -1226,6 +1268,7 @@ export default function PassengersPage() {
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
disabled={isPhoneLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1235,7 +1278,8 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
readOnly={isEmailLocked}
|
||||
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
@@ -1270,20 +1314,29 @@ export default function PassengersPage() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{isFaydaLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
@@ -1324,7 +1377,8 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
readOnly={isEmailLocked}
|
||||
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
@@ -1446,3 +1500,40 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate that waits for the persisted booking store to finish rehydrating from localStorage before
|
||||
* mounting the form. This matters on a full page load — notably returning from the Fayda redirect
|
||||
* (`/booking/passengers?code&state`) — where reading searchCriteria too early would (a) bounce to
|
||||
* home and (b) initialise react-hook-form with the wrong passenger count. Once hydrated: no
|
||||
* booking → redirect home; booking present → render the form with correct defaults.
|
||||
*/
|
||||
export default function PassengersPage() {
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (useBookingStore.persist.hasHydrated()) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const unsub = useBookingStore.persist.onFinishHydration(() => setHydrated(true));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && !searchCriteria) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [hydrated, searchCriteria]);
|
||||
|
||||
if (!hydrated || !searchCriteria) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PassengersForm />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Next.js renders this automatically the instant navigation to /booking/results
|
||||
// begins — before the route's JS has even finished downloading/compiling and
|
||||
// well before the page component mounts or its data fetch starts. That closes
|
||||
// the "I clicked Search and nothing happened" gap: previously there was no
|
||||
// visual feedback at all until the route fully loaded and hit its own isLoading
|
||||
// state. Mirrors that same isLoading skeleton so the transition is seamless.
|
||||
export default function ResultsLoading() {
|
||||
return (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Searching for trains...
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Finding the best options for your journey
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{
|
||||
animation: "progressBar 2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card animate-pulse">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-24"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="text-center space-y-2">
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right space-y-2">
|
||||
<div
|
||||
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mx-auto lg:ml-auto lg:mr-0"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
<div
|
||||
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto lg:ml-auto lg:mr-0"
|
||||
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -313,14 +313,18 @@ export default function ReviewPage() {
|
||||
}
|
||||
|
||||
// Build booking request for authenticated users
|
||||
// For package bookings, free children (first child per adult, no seat assigned)
|
||||
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
|
||||
const bookingPassengers = passengers.filter((p, i) => {
|
||||
// Package bookings only: free children (first child per adult) don't go through
|
||||
// seat selection and have no seatId, so they're excluded here — the backend derives
|
||||
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
|
||||
// (including the free child, who still gets a real seatId and a $0 fare handled by
|
||||
// the backend), so they must stay in the array or that passenger — and their
|
||||
// ticket/seat/childCount — silently never gets created.
|
||||
const bookingPassengers = passengers.filter((_p, i) => {
|
||||
if (packageId) {
|
||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
||||
return !isFreePkgChild;
|
||||
}
|
||||
return !(isChild(p) && isFirstChild(passengers, i));
|
||||
return true;
|
||||
});
|
||||
|
||||
bookingData = {
|
||||
@@ -371,14 +375,18 @@ export default function ReviewPage() {
|
||||
if (priceTierId) bookingData.priceTierId = priceTierId;
|
||||
} else {
|
||||
// For guests: send full passenger details array
|
||||
// For package bookings, free children (first child per adult, no seat assigned)
|
||||
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
|
||||
const guestBookingPassengers = passengers.filter((p, i) => {
|
||||
// Package bookings only: free children (first child per adult) don't go through
|
||||
// seat selection and have no seatId, so they're excluded here — the backend derives
|
||||
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
|
||||
// (including the free child, who still gets a real seatId and a $0 fare handled by
|
||||
// the backend), so they must stay in the array or that passenger — and their
|
||||
// ticket/seat/childCount — silently never gets created.
|
||||
const guestBookingPassengers = passengers.filter((_p, i) => {
|
||||
if (packageId) {
|
||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
||||
return !isFreePkgChild;
|
||||
}
|
||||
return !(isChild(p) && isFirstChild(passengers, i));
|
||||
return true;
|
||||
});
|
||||
|
||||
bookingData = {
|
||||
|
||||
@@ -552,6 +552,17 @@ export default function SearchPage() {
|
||||
"origin" | "destination" | null
|
||||
>(null);
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
// Immediate feedback the moment Search is clicked — router.push() itself
|
||||
// doesn't paint anything until the target route's JS has loaded, which
|
||||
// otherwise reads as a dead click.
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Warms the results route's JS chunk ahead of time so clicking Search
|
||||
// doesn't have to wait for it to download/compile on top of the actual
|
||||
// search request.
|
||||
useEffect(() => {
|
||||
router.prefetch("/booking/results");
|
||||
}, [router]);
|
||||
const [recentStationIds, setRecentStationIds] = useState<string[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
|
||||
@@ -689,6 +700,7 @@ export default function SearchPage() {
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setHasInteracted(true);
|
||||
setIsSearching(true);
|
||||
// Clear previous booking selections and search cache before starting a new search
|
||||
clearBooking();
|
||||
setSearchCriteria(data);
|
||||
@@ -715,6 +727,7 @@ export default function SearchPage() {
|
||||
// origin/destination/date errors, which is noisier than fixing things one step at a time.
|
||||
const onInvalid = (formErrors: typeof errors) => {
|
||||
setHasInteracted(true);
|
||||
setIsSearching(false);
|
||||
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
|
||||
if (formErrors.nationality && !hasOtherErrors) {
|
||||
setPassengerModalOpen(true);
|
||||
@@ -793,8 +806,12 @@ export default function SearchPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
<section className="relative h-[94vh] min-h-[560px]">
|
||||
{/* ── Hero with banner image (desktop only — mobile is content-driven, no
|
||||
forced height, so it doesn't push the Packages section below the fold).
|
||||
Desktop height is intentionally short of a full viewport so the Packages
|
||||
section peeks into view without scrolling — a full 94vh hero was hiding
|
||||
it entirely on common screen sizes. ── */}
|
||||
<section className="relative md:h-[75vh] md:min-h-[500px]">
|
||||
{/* Background image with zoom - fully isolated */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
@@ -831,18 +848,15 @@ export default function SearchPage() {
|
||||
etc.) are portaled to <body> — see ModernDatePicker — so they aren't
|
||||
capped by this wrapper's own stacking context. ── */}
|
||||
<div
|
||||
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"
|
||||
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[30] 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">
|
||||
<h1 className="text-lg 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-none md:shadow-2xl border border-white/20 overflow-visible">
|
||||
@@ -884,166 +898,174 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked */}
|
||||
{/* Mobile: stacked, but From/To and Date/Return Date pair up into two
|
||||
columns each to save vertical space (station names/dates truncate
|
||||
rather than wrap) — same fields, same behavior, just denser. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
From
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasInteracted(true);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
setStationModal("origin");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
|
||||
hasInteracted && errors.originStationId
|
||||
? "border-red-400"
|
||||
: originId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
style={{ backgroundColor: originId ? undefined : undefined }}
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span
|
||||
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
|
||||
className={`text-sm ${originStation ? 'font-semibold' : ''}`}
|
||||
>
|
||||
{originStation?.name ?? "Select departure"}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<div className="h-5 flex items-center">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
From
|
||||
</label>
|
||||
</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">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
To
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
disabled={!originId || !destId}
|
||||
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30"
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
|
||||
/>
|
||||
Swap
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasInteracted(true);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
setStationModal("destination");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
|
||||
hasInteracted && errors.destinationStationId
|
||||
? "border-red-400"
|
||||
: destId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span
|
||||
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
|
||||
className={`text-sm ${destStation ? 'font-semibold' : ''}`}
|
||||
>
|
||||
{destStation?.name ?? "Select destination"}
|
||||
</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">
|
||||
Date
|
||||
</label>
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: undefined
|
||||
}
|
||||
onChange={(date) => {
|
||||
setValue(
|
||||
"departureDate",
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
trigger("departureDate");
|
||||
onClick={() => {
|
||||
setHasInteracted(true);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
setStationModal("origin");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
className="w-full"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
|
||||
hasInteracted && errors.originStationId
|
||||
? "border-red-400"
|
||||
: originId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
style={{ backgroundColor: originId ? undefined : undefined }}
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span
|
||||
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
|
||||
className={`text-sm truncate ${originStation ? 'font-semibold' : ''}`}
|
||||
>
|
||||
{originStation?.name ?? "Departure"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.originStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<div className="h-5 flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
To
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
disabled={!originId || !destId}
|
||||
aria-label="Swap origin and destination"
|
||||
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasInteracted(true);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
setStationModal("destination");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
|
||||
hasInteracted && errors.destinationStationId
|
||||
? "border-red-400"
|
||||
: destId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span
|
||||
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
|
||||
className={`text-sm truncate ${destStation ? 'font-semibold' : ''}`}
|
||||
>
|
||||
{destStation?.name ?? "Destination"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.destinationStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.departureDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tripType === "ROUND_TRIP" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Return Date
|
||||
Date
|
||||
</label>
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
returnDate
|
||||
? new Date(returnDate + "T00:00:00")
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: undefined
|
||||
}
|
||||
onChange={(date) => {
|
||||
setValue(
|
||||
"returnDate",
|
||||
"departureDate",
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
trigger("returnDate");
|
||||
trigger("departureDate");
|
||||
}}
|
||||
minDate={
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select return date"
|
||||
error={!!errors.returnDate}
|
||||
minDate={new Date()}
|
||||
placeholder="Departure date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.returnDate.message}
|
||||
{errors.departureDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tripType === "ROUND_TRIP" && (
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Return Date
|
||||
</label>
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
returnDate
|
||||
? new Date(returnDate + "T00:00:00")
|
||||
: undefined
|
||||
}
|
||||
onChange={(date) => {
|
||||
setValue(
|
||||
"returnDate",
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={
|
||||
departureDate
|
||||
? new Date(departureDate + "T00:00:00")
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Return date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.returnDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Pax + Nationality combined trigger */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1066,11 +1088,20 @@ export default function SearchPage() {
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full text-sm"
|
||||
disabled={isLoading || isSearching}
|
||||
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1207,11 +1238,20 @@ export default function SearchPage() {
|
||||
{/* Search */}
|
||||
<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 transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
disabled={isLoading || isSearching}
|
||||
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-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1282,7 +1322,7 @@ export default function SearchPage() {
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
placeholder="Departure date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
|
||||
@@ -1298,7 +1338,7 @@ export default function SearchPage() {
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
||||
placeholder="Select date"
|
||||
placeholder="Return date"
|
||||
/>
|
||||
{errors.returnDate && (
|
||||
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
||||
@@ -1328,11 +1368,20 @@ export default function SearchPage() {
|
||||
{/* Search */}
|
||||
<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"
|
||||
disabled={isLoading || isSearching}
|
||||
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-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -275,8 +275,8 @@ export default function Contact() {
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{ icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' },
|
||||
{ icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' },
|
||||
{ icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' },
|
||||
{ icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' },
|
||||
{ icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' },
|
||||
];
|
||||
|
||||
|
||||
@@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.',
|
||||
},
|
||||
{
|
||||
question: 'How do I modify my booking?',
|
||||
answer:
|
||||
'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.',
|
||||
},
|
||||
{
|
||||
question: 'What is the cancellation policy?',
|
||||
answer:
|
||||
'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -84,28 +74,13 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
{
|
||||
question: 'What are the passenger age categories?',
|
||||
answer:
|
||||
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.',
|
||||
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child per adult travels free, and any additional children pay the full fare.',
|
||||
},
|
||||
{
|
||||
question: 'How is a child\'s age determined?',
|
||||
answer:
|
||||
'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.',
|
||||
},
|
||||
{
|
||||
question: 'Example: how much does a family of 2 adults + 3 children pay?',
|
||||
answer:
|
||||
'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.',
|
||||
},
|
||||
{
|
||||
question: 'What seat classes are available?',
|
||||
answer:
|
||||
'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.',
|
||||
},
|
||||
{
|
||||
question: 'What is the nationality field for?',
|
||||
answer:
|
||||
'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -153,11 +128,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.',
|
||||
},
|
||||
{
|
||||
question: 'Are prices shown in my local currency?',
|
||||
answer:
|
||||
'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -167,22 +137,12 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
{
|
||||
question: 'What payment methods are accepted?',
|
||||
answer:
|
||||
'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.',
|
||||
},
|
||||
{
|
||||
question: 'What is the EDR Wallet?',
|
||||
answer:
|
||||
'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.',
|
||||
},
|
||||
{
|
||||
question: 'When will I receive my refund?',
|
||||
answer:
|
||||
'Refunds are processed within 5–7 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.',
|
||||
'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. You can choose your preferred method at checkout.',
|
||||
},
|
||||
{
|
||||
question: 'Is my payment information secure?',
|
||||
answer:
|
||||
'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
|
||||
'Yes. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -226,16 +186,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
|
||||
answer:
|
||||
'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.',
|
||||
},
|
||||
{
|
||||
question: 'How do I set up Verifayda on my account?',
|
||||
answer:
|
||||
'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.',
|
||||
},
|
||||
{
|
||||
question: 'Can I use the app in multiple languages?',
|
||||
answer:
|
||||
'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import dynamic from 'next/dynamic';
|
||||
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';
|
||||
|
||||
// AppSidebar renders on every page via the root layout, so anything imported
|
||||
// here ships to every visitor's first load — but this modal is only ever
|
||||
// reachable by an already-authenticated user opening the account dropdown.
|
||||
// Code-split it out instead of paying for it on every page/every visitor.
|
||||
const ChangePasswordModal = dynamic(() => import('@/components/ChangePasswordModal'), { ssr: false });
|
||||
|
||||
// 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> = {
|
||||
@@ -203,10 +209,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
{showChangePassword && (
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,14 +286,14 @@ export default function ModernDatePicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
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 ${
|
||||
className={`w-full min-w-0 px-2.5 sm: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 gap-1.5 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}
|
||||
<span className={`text-sm whitespace-nowrap truncate ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
|
||||
{value ? format(value, 'MMM d, yyyy') : placeholder}
|
||||
</span>
|
||||
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -12,27 +12,11 @@ export default function ThemeToggle() {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
} else if (theme === 'dark') {
|
||||
setTheme('system');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
};
|
||||
const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
|
||||
const getIcon = () => {
|
||||
if (theme === 'light') return <Sun className="w-5 h-5" />;
|
||||
if (theme === 'dark') return <Moon className="w-5 h-5" />;
|
||||
return <Monitor className="w-5 h-5" />;
|
||||
};
|
||||
const getIcon = () => theme === 'dark' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />;
|
||||
|
||||
const getLabel = () => {
|
||||
if (theme === 'light') return 'Light';
|
||||
if (theme === 'dark') return 'Dark';
|
||||
return 'System';
|
||||
};
|
||||
const getLabel = () => theme === 'dark' ? 'Dark' : 'Light';
|
||||
|
||||
// Prevent hydration mismatch by not rendering until mounted
|
||||
if (!mounted) {
|
||||
|
||||
@@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
|
||||
|
||||
// ─── header ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fetched once and reused for the lifetime of the page — re-fetching this same static
|
||||
// asset on every passenger/every voucher adds a real network round-trip in the middle of
|
||||
// what needs to stay close to the original click's synchronous execution window (iOS
|
||||
// Safari silently blocks a file save triggered too long after user activation).
|
||||
let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null;
|
||||
function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||
if (!logoCache) {
|
||||
logoCache = (async () => {
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoBlob = await logoImg.blob();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(logoBlob);
|
||||
});
|
||||
const img = new Image();
|
||||
await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; });
|
||||
return { dataUrl, width: img.width, height: img.height };
|
||||
})();
|
||||
}
|
||||
return logoCache;
|
||||
}
|
||||
|
||||
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const bandHeight = 24;
|
||||
@@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||
doc.rect(0, 0, pageWidth, bandHeight, 'F');
|
||||
|
||||
try {
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoBlob = await logoImg.blob();
|
||||
const logoDataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(logoBlob);
|
||||
});
|
||||
const img = new Image();
|
||||
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
|
||||
const { dataUrl: logoDataUrl, width, height } = await loadLogo();
|
||||
const logoH = 13;
|
||||
const logoW = (img.width / img.height) * logoH;
|
||||
const logoW = (width / height) * logoH;
|
||||
const textX = margin + logoW + 5;
|
||||
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
@@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
|
||||
|
||||
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
|
||||
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
||||
doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
||||
doc.setFontSize(6.5);
|
||||
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise<void> {
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const margin = PAGE_MARGIN;
|
||||
|
||||
@@ -388,6 +401,12 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
|
||||
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
|
||||
drawInstructions(doc, y, margin, pageW);
|
||||
drawFooter(doc, data.createdAt);
|
||||
}
|
||||
|
||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
await drawPassengerVoucherPage(doc, data);
|
||||
|
||||
const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
doc.save(`Voucher_${safeName}.pdf`);
|
||||
@@ -404,14 +423,28 @@ interface VoucherData {
|
||||
currency: string;
|
||||
bookingType: string;
|
||||
createdAt: string;
|
||||
// One ticket per passenger, matched below by passengerName — see bookings.service.ts's
|
||||
// getByRef(). Optional/absent falls back to a client-generated placeholder number.
|
||||
tickets?: Array<{ passengerName?: string; barcodePayload?: string }>;
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
|
||||
// them — a setTimeout delay here would push later saves outside the click's synchronous
|
||||
// user-activation window and risk iOS Safari silently blocking them. The awaited work
|
||||
// inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode),
|
||||
// which doesn't have that effect.
|
||||
for (let i = 0; i < booking.passengers.length; i++) {
|
||||
const p = booking.passengers[i];
|
||||
const matchedTicket =
|
||||
booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? null;
|
||||
// No fabricated placeholder — a made-up TKT-... number reads as real and is misleading
|
||||
// if it doesn't match what's actually on file.
|
||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: booking.bookingRef,
|
||||
ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`,
|
||||
ticketNumber,
|
||||
passengerName: p.fullName,
|
||||
seatNumber: p.seat?.number,
|
||||
status: booking.status,
|
||||
@@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
currency: booking.currency,
|
||||
createdAt: booking.createdAt,
|
||||
});
|
||||
// small delay so browsers don't block multiple sequential downloads
|
||||
if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user