Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI

This commit is contained in:
yaschalew
2026-07-11 08:35:09 +03:00
62 changed files with 1471 additions and 553 deletions

View File

@@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService, BookingPricingService,
BookingInvoiceService, BookingInvoiceService,
BookingLifecycleNotifierService, BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService, ConsolidationService,
CustomerTruckService, CustomerTruckService,
ContainerReceiptService, ContainerReceiptService,

View File

@@ -804,9 +804,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
}); });
} }
if (options.bookingType) { if (options.bookingType) {
qb.andWhere('booking.bookingType = :bookingType', { // The stored booking_type column is 'ONE_TIME' for every row (contract
bookingType: options.bookingType, // 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) { if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', { qb.andWhere('booking.created_at >= :createdFrom', {

View File

@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // invoiceService {} as never, // invoiceService
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingTransitionService
); );
return { service, contractsRepository }; return { service, contractsRepository };
} }

View File

@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never, invoiceService as never,
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingTransitionService
); );
return { return {
service, service,

View File

@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { ConsolidationService } from '../bookings/consolidation.service'; import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service';
@@ -72,6 +73,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService)) @Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {} ) {}
async createUnderContract( async createUnderContract(
@@ -331,6 +334,227 @@ export class ContractBookingService {
return { booking: result ?? booking, warnings }; 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 * 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. * park it in PENDING_CONSOLIDATION with the resume status it should return to.

View File

@@ -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') @Post(':id/validate-shipment')
@ApiOperation({ @ApiOperation({
summary: summary:

View File

@@ -197,12 +197,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Send />, icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking, permission: FREIGHT_PERMS.contracts.createBooking,
}, },
// { // Operations Path A queue: per-booking self-clearance review for
// label: "Self-Clearance Review", // GENERAL non-customs booking instances (and legacy self-clear bookings).
// href: "/dashboard/contracts/ops-clearance", {
// icon: <ShieldCheck />, label: "Self-Clearance Review",
// permission: FREIGHT_PERMS.contracts.opsClearanceReview, href: "/dashboard/contracts/ops-clearance",
// }, icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{ {
label: "GL Djibouti Clearance", label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance", href: "/dashboard/gl-djibouti/clearance",

View File

@@ -380,6 +380,31 @@ export function ClearanceReviewSection({
</Text> </Text>
</Group> </Group>
</Paper> </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"> <Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">

View File

@@ -308,6 +308,12 @@ const App = () => {
path="/contracts/:id/bookings/new" path="/contracts/:id/bookings/new"
element={<NewShipmentPage />} 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 <Route
path="/contracts/:id/clearance" path="/contracts/:id/clearance"
element={<ContractClearanceFlow />} element={<ContractClearanceFlow />}

View File

@@ -1,10 +1,14 @@
import { Button, Group, type ButtonProps } from "@mantine/core"; 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 type { ReactNode } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction"; import { ContractClearanceAction } from "./ContractClearanceAction";
import { deriveContractCustomerAction } from "./deriveContractCustomerAction"; import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
@@ -56,6 +60,18 @@ export function ContractCustomerAction({
return <PayNowButton booking={action.booking} label={action.label} size={size} />; 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 Icon = action.icon;
const variant = action.primary ? "filled" : "light"; 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. */ /** Action column cell: doc button + primary customer action. */
export function ContractCustomerActionCell({ export function ContractCustomerActionCell({
contract, contract,

View File

@@ -72,6 +72,14 @@ export type ContractCustomerAction =
label: string; label: string;
primary: boolean; primary: boolean;
icon: LucideIcon; 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( function findPayableBookingForContract(
@@ -210,6 +218,15 @@ export function deriveContractCustomerAction(
} }
const bookingAction = getContractBookingAction(contract, bookings); const bookingAction = getContractBookingAction(contract, bookings);
if (bookingAction.kind === "initiate") {
return {
type: "initiate",
contract,
label: "Initiate booking",
primary: true,
icon: PackagePlus,
};
}
if (bookingAction.kind === "book") { if (bookingAction.kind === "book") {
return { return {
type: "navigate", type: "navigate",

View File

@@ -142,6 +142,9 @@ export const URL_CONSTANTS = {
`/api/contracts/${id}/clearance/documents`, `/api/contracts/${id}/clearance/documents`,
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`, CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`, 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) => VALIDATE_SHIPMENT: (id: string) =>
`/api/contracts/${id}/validate-shipment`, `/api/contracts/${id}/validate-shipment`,
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`, MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,

View File

@@ -1,29 +1,28 @@
import { Alert, Button, Group } from "@mantine/core"; import { useState } from "react";
import { CheckCircle2, Upload } from "lucide-react"; import { Alert, Button, Group, Text } from "@mantine/core";
import { useNavigate } from "react-router-dom"; import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CardTitle, SectionCard } from "./layout"; import { CardTitle, SectionCard } from "./layout";
/** /**
* Customer-facing clearance section on the booking detail page: shows the * Customer-facing clearance section on the booking detail page: a compact
* resolved document grid, lets the customer (re)upload pending/queried documents * status summary with a single action button. The document grid, re-uploads,
* plus ad-hoc named documents, and proceed to operation once Global Logistics * and the shipment-day picker all live in the shared {@link BookingActionModal}
* marks the booking CLEARANCE_READY. * (the same modal the My Shipments list uses), so the flow behaves identically
* * from both entry points.
* The flow body, calendar, and mutations are shared with the home-page action
* modal via `useClearanceFlow` / `ClearanceFlow`.
*/ */
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate(); const [modalOpen, setModalOpen] = useState(false);
const flow = useClearanceFlow(booking); const status = booking.status as string;
const action = getBookingNextAction(booking);
if (flow.status === "OPERATION_REQUESTED") { if (status === "OPERATION_REQUESTED") {
return ( return (
<SectionCard> <SectionCard>
<CardTitle>Operation</CardTitle> <CardTitle>Operation</CardTitle>
@@ -34,56 +33,51 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
); );
} }
if (flow.isLoading || !flow.clearance) { const summary =
return ( status === "CLEARANCE_READY" ? (
<SectionCard> <Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
<CardTitle>Clearance documents</CardTitle> Clearance is complete. Pick a shipment day and proceed to operation.
</SectionCard> </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 ( return (
<SectionCard> <SectionCard>
<BookingClearanceWorkflowBanner booking={booking} /> <BookingClearanceWorkflowBanner booking={booking} />
<Group justify="space-between" align="center" mb="md" mt="md"> <Group justify="space-between" align="center" mb="md" mt="md">
<CardTitle>Clearance documents</CardTitle> <CardTitle>Clearance documents</CardTitle>
{action && (
<Button
color="edr-green"
radius="md"
leftSection={<ClipboardList size={16} />}
onClick={() => setModalOpen(true)}
>
{action.label}
</Button>
)}
</Group> </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} booking={booking}
flow={flow} opened={modalOpen}
footer={ onClose={() => setModalOpen(false)}
<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>
}
/> />
</SectionCard> </SectionCard>
); );

View File

@@ -16,14 +16,23 @@ export const PROGRESS_STAGES = [
statuses: ["DRAFT", "CHANGES_REQUESTED"], 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", label: "Submitted",
icon: ClipboardCheck, icon: ClipboardCheck,
statuses: ["SUBMITTED"], statuses: ["SUBMITTED", "AWAITING_DOCUMENTS"],
}, },
{ {
// Clearance review is the approval step for contract-drawdown bookings.
label: "Approval", label: "Approval",
icon: ShieldCheck, icon: ShieldCheck,
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"], statuses: [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
],
}, },
{ {
label: "Contract", label: "Contract",
@@ -37,6 +46,7 @@ export const PROGRESS_STAGES = [
"FULLY_EXECUTED", "FULLY_EXECUTED",
"SELECTED_FOR_BATCH", "SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS", "PAYMENT_VERIFICATION_IN_PROGRESS",
"OPERATION_REQUESTED",
], ],
}, },
{ {
@@ -234,24 +244,24 @@ export const STATUS_MAP: Record<
title: "Clearance documents needed", title: "Clearance documents needed",
description: description:
"Upload the required clearance documents so your shipment can be reviewed.", "Upload the required clearance documents so your shipment can be reviewed.",
stage: 5, stage: 1,
}, },
DOCUMENTS_UNDER_REVIEW: { DOCUMENTS_UNDER_REVIEW: {
title: "Documents under review", title: "Documents under review",
description: description:
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.", "Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
stage: 5, stage: 2,
}, },
CLEARANCE_READY: { CLEARANCE_READY: {
title: "Cleared — choose a shipment day", title: "Cleared — choose a shipment day",
description: description:
"Clearance is complete. Pick a shipment day and proceed to operation.", "Clearance is complete. Pick a shipment day and proceed to operation.",
stage: 5, stage: 2,
}, },
OPERATION_REQUESTED: { OPERATION_REQUESTED: {
title: "Operation requested", title: "Operation requested",
description: "Operation requested. An operator will take your shipment forward.", description: "Operation requested. An operator will take your shipment forward.",
stage: 5, stage: 4,
}, },
CONTRACT_ACTIVE: { CONTRACT_ACTIVE: {
title: "Contract active", title: "Contract active",

View File

@@ -1,5 +1,6 @@
import { Box, Button, Group, Modal, Text } from "@mantine/core"; 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"; import type { Freight } from "@edr/types";
@@ -40,6 +41,7 @@ function BookingActionModalBody({
}) { }) {
const action = getBookingNextAction(booking); const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking); const flow = useClearanceFlow(booking);
const navigate = useNavigate();
const reference = booking.reference; const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose }); const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
@@ -91,17 +93,31 @@ function BookingActionModalBody({
Submit documents Submit documents
</Button> </Button>
)} )}
{flow.isReady && ( {flow.needsCompletion && flow.completeTo ? (
<Button <Button
color="edr-green" color="edr-green"
radius="md" radius="md"
leftSection={<CheckCircle2 size={16} />} leftSection={<PackagePlus size={16} />}
onClick={handleProceed} onClick={() => {
loading={flow.proceedMutation.isPending} onClose();
disabled={!flow.scheduledDate} navigate(flow.completeTo!);
}}
> >
Proceed to operation Complete booking
</Button> </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> </Group>
} }

View File

@@ -54,6 +54,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
customerDocs, customerDocs,
glDocs, glDocs,
isReady, isReady,
needsCompletion,
canUpload, canUpload,
isInitialUpload, isInitialUpload,
status, status,
@@ -78,9 +79,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
<Stack gap={0}> <Stack gap={0}>
{isReady ? ( {isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md"> <Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms {needsCompletion
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation." ? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
: "Clearance is ready. You can now proceed to operation."} : 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> </Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? ( ) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md"> <Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
@@ -208,7 +211,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</Alert> </Alert>
)} )}
{isReady && ( {isReady && !needsCompletion && (
<Box mt="lg"> <Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}> <Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day Choose your shipment day

View File

@@ -67,6 +67,16 @@ export function useClearanceFlow(booking: Freight.IBooking) {
); );
const isReady = status === "CLEARANCE_READY"; 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 = const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
// The very first upload (nothing in review yet). Here every required document // The very first upload (nothing in review yet). Here every required document
@@ -146,6 +156,8 @@ export function useClearanceFlow(booking: Freight.IBooking) {
customerDocs, customerDocs,
glDocs, glDocs,
isReady, isReady,
needsCompletion,
completeTo,
canUpload, canUpload,
isInitialUpload, isInitialUpload,
// staged upload state // staged upload state

View File

@@ -61,6 +61,7 @@ import { labelForDocCode } from "@/pages/bookings/resubmit";
import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action"; import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -348,6 +349,9 @@ export default function ContractDetailPage() {
const canBookShipment = const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook"; bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request"; 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 // Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action. // status notice instead of any action.
const glPreparingBooking = customsPath && clearanceFinalized; const glPreparingBooking = customsPath && clearanceFinalized;
@@ -438,6 +442,13 @@ export default function ContractDetailPage() {
Request shipment Request shipment
</Button> </Button>
)} )}
{canInitiateBooking && (
<InitiateBookingButton
contract={contract}
icon={PackagePlus}
size="md"
/>
)}
{canBookShipment && {canBookShipment &&
(bookingWindowOpen ? ( (bookingWindowOpen ? (
<Button <Button
@@ -1289,6 +1300,13 @@ export default function ContractDetailPage() {
> >
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel> <SectionLabel>Bookings under this contract</SectionLabel>
{canInitiateBooking && (
<InitiateBookingButton
contract={contract}
icon={PackagePlus}
size="xs"
/>
)}
{canBookShipment && bookingWindowOpen && ( {canBookShipment && bookingWindowOpen && (
<Button <Button
color="edr-green" color="edr-green"

View File

@@ -77,7 +77,12 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
}; };
export default function NewShipmentPage() { 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 navigate = useNavigate();
const { data: contract, isLoading } = useQuery( 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( function bulkUnitOfMeasure(
@@ -219,9 +230,12 @@ function bulkUnitOfMeasure(
function NewShipmentBookingForm({ function NewShipmentBookingForm({
contract, contract,
contractId, contractId,
completeBookingId,
}: { }: {
contract: Freight.IContract; contract: Freight.IContract;
contractId: string; contractId: string;
/** Set when completing an initiated (bare) booking after clearance. */
completeBookingId?: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -251,7 +265,13 @@ function NewShipmentBookingForm({
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) => 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) => { onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -368,10 +388,12 @@ function NewShipmentBookingForm({
> >
<Box> <Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking {completeBookingId ? "Complete Your Booking" : "New Shipment Booking"}
</Title> </Title>
<Text size="sm" c="edr-muted" mt={4}> <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> </Text>
</Box> </Box>
<Button <Button
@@ -398,7 +420,9 @@ function NewShipmentBookingForm({
mb="lg" mb="lg"
> >
<Text size="sm" fw={600}> <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>
<Text size="sm" mt={4} c="red.7"> <Text size="sm" mt={4} c="red.7">
{submitMutation.error instanceof Error {submitMutation.error instanceof Error

View File

@@ -15,7 +15,12 @@ export const TERMINAL_BOOKING_STATUSES = [
/** Path A statuses where a customer (no customs) may book against the contract. */ /** Path A statuses where a customer (no customs) may book against the contract. */
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"]; 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 { export interface ContractBookingAction {
kind: ContractBookingActionKind; kind: ContractBookingActionKind;
@@ -63,5 +68,13 @@ export function getContractBookingAction(
return { kind: hasExpired ? "rebook" : "book", to }; 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 }; return { kind: "book", to };
} }

View File

@@ -531,6 +531,24 @@ export const api = {
contractsService.createBookingUnderContract(id, dto), 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< validateShipment: endpoint<
{ id: string; dto: Freight.CreateBookingUnderContractDto }, { id: string; dto: Freight.CreateBookingUnderContractDto },
ShipmentValidation ShipmentValidation

View File

@@ -329,6 +329,32 @@ export const contractsService = {
return data.data.booking ?? data.data; 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 * Pre-submit validation of a shipment booking (same DTO as
* `createBookingUnderContract`). Returns overweight warnings and hard-block * `createBookingUnderContract`). Returns overweight warnings and hard-block

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Agents') @ApiTags('Agents')
@Controller('agents') @Controller('agents')
@@ -36,6 +37,8 @@ export class AgentsController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete agent profile' }) @ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) { deleteAgent(@Param('id') id: string) {
return this.service.deleteAgent(id); return this.service.deleteAgent(id);

View File

@@ -7,6 +7,7 @@ import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Booking') @ApiTags('Booking')
@Controller('bookings') @Controller('bookings')
@@ -466,7 +467,8 @@ export class BookingsController {
} }
@Delete(':id') @Delete(':id')
@SetMetadata('isPublic', true) @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
description: 'Permanently deletes a booking record' description: 'Permanently deletes a booking record'
}) })

View File

@@ -1460,7 +1460,7 @@ export class BookingsService {
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: { take: 1 }, paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } }, priceTier: { select: { priceMinor: true } },
}, },
}); });
@@ -1518,7 +1518,7 @@ export class BookingsService {
payment: (pkgBooking as any).paymentIntent payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
: undefined, : 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, 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,
})) ?? [],
}; };
} }

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { CurrenciesService } from './currencies.service'; import { CurrenciesService } from './currencies.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';

View File

@@ -13,7 +13,7 @@ export class CurrenciesService {
async getAllCurrencies() { async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({ const rates = await this.prisma.currencyExchangeRate.findMany({
distinct: ['toCurrency'], distinct: ['toCurrency'],
orderBy: { toCurrency: 'asc' }, orderBy: { createdAt: 'desc' },
}); });
const base = { const base = {
@@ -83,12 +83,14 @@ export class CurrenciesService {
throw new BadRequestException('Exchange rate must be positive'); throw new BadRequestException('Exchange rate must be positive');
} }
const updated = await this.prisma.currencyExchangeRate.update({ // Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up
where: { id }, const updated = await this.currencyService.upsertRate(
data: { existing.fromCurrency,
rate: dto.exchangeRate, existing.toCurrency,
}, dto.exchangeRate ?? Number(existing.rate),
}); undefined,
'MANUAL',
);
return { return {
id: updated.id, id: updated.id,
@@ -112,8 +114,9 @@ export class CurrenciesService {
throw new NotFoundException('Currency not found'); throw new NotFoundException('Currency not found');
} }
await this.prisma.currencyExchangeRate.delete({ // Delete all records for this currency pair so no stale rates remain
where: { id }, await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
}); });
return { message: 'Currency deleted successfully' }; return { message: 'Currency deleted successfully' };

View File

@@ -8,6 +8,7 @@ import {
InitiateExcessPaymentDto, InitiateExcessPaymentDto,
} from './excess-baggage.dto'; } from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
class UpsertBaggageAllowanceDto { class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string; @IsString() seatClassId: string;
@@ -70,6 +71,8 @@ export class ExcessBaggageAgentController {
} }
@Delete('allowances/:id') @Delete('allowances/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete baggage allowance rule' }) @ApiOperation({ summary: 'Delete baggage allowance rule' })
deleteAllowance(@Param('id') id: string) { deleteAllowance(@Param('id') id: string) {
return this.service.deleteAllowance(id); return this.service.deleteAllowance(id);
@@ -94,6 +97,8 @@ export class ExcessBaggageAgentController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) { deleteCharge(@Param('id') id: string) {
return this.service.deleteCharge(id); return this.service.deleteCharge(id);

View File

@@ -1,9 +1,10 @@
import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common'; 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 { CurrencyService } from '../currency/currency.service';
import { UpsertExchangeRateDto } from './currency.dto'; import { UpsertExchangeRateDto } from './currency.dto';
import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator'; import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { PassengerAdmin } from '../../common/passenger-guards';
class UpdateExchangeRateDto { class UpdateExchangeRateDto {
@ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number; @ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
@@ -38,6 +39,8 @@ export class CurrencyController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete an exchange rate record by ID' }) @ApiOperation({ summary: 'Delete an exchange rate record by ID' })
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' }) @ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
@ApiResponse({ status: 200, description: 'Rate deleted' }) @ApiResponse({ status: 200, description: 'Rate deleted' })

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service'; import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Fleet') @ApiTags('Fleet')
@Controller('fleet') @Controller('fleet')
@@ -38,6 +39,8 @@ export class FleetController {
} }
@Delete('coach-types/:id') @Delete('coach-types/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach type' }) @ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiResponse({ status: 200, description: 'Coach type deleted' }) @ApiResponse({ status: 200, description: 'Coach type deleted' })
@@ -74,6 +77,8 @@ export class FleetController {
} }
@Delete('classes/:id') @Delete('classes/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class' }) @ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @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') @Delete('seat-classes/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@@ -147,6 +154,8 @@ export class FleetController {
} }
@Delete('trains/:id') @Delete('trains/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a train service' }) @ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@@ -310,6 +319,8 @@ export class FleetController {
} }
@Delete('coaches/:id') @Delete('coaches/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach' }) @ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@@ -329,6 +340,8 @@ export class FleetController {
} }
@Delete('assignments/:id') @Delete('assignments/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiParam({ name: 'id', description: 'Assignment UUID' })
@ApiResponse({ status: 200, description: 'Assignment removed' }) @ApiResponse({ status: 200, description: 'Assignment removed' })

View File

@@ -6,6 +6,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
import { IamGuard } from '../../common/iam-adapter'; import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Packages') @ApiTags('Packages')
@Controller('packages') @Controller('packages')
@@ -41,7 +42,7 @@ export class PackagesController {
} }
@Delete('inquiries/:id') @Delete('inquiries/:id')
@UseGuards(IamGuard) @PassengerAdmin()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete inquiry (backoffice)' }) @ApiOperation({ summary: 'Delete inquiry (backoffice)' })
deleteInquiry(@Param('id') id: string) { deleteInquiry(@Param('id') id: string) {
@@ -139,7 +140,7 @@ export class PackagesController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(IamGuard) @PassengerAdmin()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' }) @ApiOperation({ summary: 'Delete package (admin)' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
@@ -180,7 +181,7 @@ export class PackagesController {
} }
@Delete('tiers/:tierId') @Delete('tiers/:tierId')
@UseGuards(IamGuard) @PassengerAdmin()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' }) @ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) { deleteTier(@Param('tierId') tierId: string) {

View File

@@ -4,6 +4,7 @@ import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { PassengersService } from './passengers.service'; import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@@ -503,7 +504,8 @@ Returns saved passenger details with generated IDs and confirmation.`,
} }
@Delete(':id') @Delete(':id')
@SetMetadata('isPublic', true) @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Delete passenger (admin only)', summary: 'Delete passenger (admin only)',
description: 'Permanently deletes a passenger record and associated data' description: 'Permanently deletes a passenger record and associated data'

View File

@@ -99,6 +99,8 @@ export class PaymentsService {
priceTierId: true, priceTierId: true,
adultCount: true, adultCount: true,
childCount: true, childCount: true,
totalMinor: true,
currency: true,
priceTier: { select: { priceMinor: true } }, priceTier: { select: { priceMinor: true } },
}, },
}, },
@@ -129,7 +131,7 @@ export class PaymentsService {
id: item.id, id: item.id,
reference: item.id.substring(0, 8), reference: item.id.substring(0, 8),
bookingId: item.bookingId, bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef }, booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
amountMinor, amountMinor,
currency: item.currency, currency: item.currency,
method: item.method, method: item.method,

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service'; import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto'; import { CreatePromotionDto } from './promos.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Promotions') @ApiTags('Promotions')
@Controller('promos') @Controller('promos')
@@ -64,8 +65,8 @@ export class PromosController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard) @PassengerAdmin()
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete promo (admin)' }) @ApiOperation({ summary: 'Delete promo (admin)' })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {
return this.service.delete(id); return this.service.delete(id);

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse }
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Routes') @ApiTags('Routes')
@Controller('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); } updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route' }) @ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @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); } addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
@Delete(':id/stops/:sequence') @Delete(':id/stops/:sequence')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' }) @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') @Delete(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Template cleared' }) @ApiResponse({ status: 200, description: 'Template cleared' })

View File

@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Schedule') @ApiTags('Schedule')
@Controller('schedules') @Controller('schedules')
@@ -56,7 +57,8 @@ export class SchedulesController {
} }
@Delete('fares/:id') @Delete('fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a fare rule' }) @ApiOperation({ summary: 'Delete a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule deleted' }) @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); } updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id') @Delete('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
@@ -110,7 +113,8 @@ export class SchedulesController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a schedule' }) @ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean }) @ApiQuery({ name: 'cascade', required: false, type: Boolean })
@@ -203,7 +207,8 @@ export class SchedulesController {
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
@Delete(':id/coaches/:coachId') @Delete(':id/coaches/:coachId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'coachId', description: 'Coach UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' })

View File

@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
import { SeatClassesService } from './seat-classes.service'; import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Seat Classes') @ApiTags('Seat Classes')
@Controller('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); } updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a seat class' }) @ApiOperation({ summary: 'Delete a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Seat class deleted' }) @ApiResponse({ status: 200, description: 'Seat class deleted' })

View File

@@ -29,6 +29,11 @@ export class CreateSeatClassDto {
@IsInt() @IsInt()
basePrice: number; basePrice: number;
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
@IsOptional()
@IsInt()
insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @ApiPropertyOptional({ example: true })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -837,13 +837,16 @@ export class SeatsService {
async removeSeat(seatId: string) { async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); 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({ await this.prisma.seat.update({
where: { id: seatId }, where: { id: seatId },
data: { seatNumber: `-${seat.seatNumber}` }, data: { seatNumber: `-${seat.seatNumber}` },
}); });
await this.renumberCoachSeats(seat.coachId);
return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
} }
@@ -854,10 +857,38 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed'); throw new BadRequestException('Seat is not removed');
} }
const originalNumber = seat.seatNumber.slice(1); // Restore with a temporary placeholder number, then renumber
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); 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) @Cron(CronExpression.EVERY_MINUTE)

View File

@@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de
import { StationsService } from './stations.service'; import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto'; import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Stations') @ApiTags('Stations')
@Controller('stations') @Controller('stations')
@@ -133,8 +134,8 @@ export class StationsController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtGuard) @PassengerAdmin()
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete station' }) @ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Station deleted successfully' }) @ApiResponse({ status: 200, description: 'Station deleted successfully' })

View File

@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = { export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', 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_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -15,6 +16,7 @@ export const CONFIG_KEYS = {
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', [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_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Tickets') @ApiTags('Tickets')
@Controller('tickets') @Controller('tickets')
@@ -9,7 +10,8 @@ export class TicketsController {
constructor(private service: TicketsService) {} constructor(private service: TicketsService) {}
@Post('generate/:bookingId') @Post('generate/:bookingId')
@SetMetadata('isPublic', true) @PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Generate ticket for booking (confirmation page)', 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.' description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'

View File

@@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, SystemConfigModule],
controllers: [TicketsController], controllers: [TicketsController],
providers: [TicketsService, JwtGuard], providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard],

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
interface OfflineValidation { interface OfflineValidation {
@@ -20,6 +21,7 @@ export class TicketsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly systemConfig: SystemConfigService,
@InjectDataSource() private readonly dataSource: DataSource, @InjectDataSource() private readonly dataSource: DataSource,
) {} ) {}
@@ -423,26 +425,20 @@ export class TicketsService {
// Check if ticket date matches today // Check if ticket date matches today
const today = new Date(); const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) { if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt); const departureTime = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0]; const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
// Check if ticket is for today if (today < boardingOpenTime) {
if (departureDateStr !== todayDateStr) { throw new BadRequestException(
if (departureDateStr < todayDateStr) { `Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`,
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 if (today >= departureTime) {
const departureTime = new Date((booking as any).schedule.departureAt); throw new BadRequestException('Boarding is closed — departure time has passed');
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');
} }
} }

View File

@@ -254,12 +254,16 @@ function BookingsPageContent() {
}, },
{ {
key: 'contact', label: 'Primary contact', key: 'contact', label: 'Primary contact',
render: (booking: any) => ( render: (booking: any) => {
<div> const phone = booking.contactPhone || booking.passenger?.phone || '—';
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div> const email = booking.contactEmail || booking.passenger?.email || '—';
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div> return (
</div> <div>
), <div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground truncate" title={email}>{email}</div>
</div>
);
},
}, },
{ {
key: 'paymentStatus', label: 'Payment', key: 'paymentStatus', label: 'Payment',

View File

@@ -146,7 +146,13 @@ export default function CurrenciesPage() {
const actions = [ const actions = [
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, { 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 ( return (

View File

@@ -93,7 +93,7 @@ export default function PaymentsPage() {
switch (key) { switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A'; 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 'method': return payment.method || '';
case 'status': return payment.status || ''; case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : ''; case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
@@ -116,7 +116,7 @@ export default function PaymentsPage() {
const columns = [ const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> }, { 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: '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: '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: '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) }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
@@ -204,7 +204,7 @@ export default function PaymentsPage() {
</div> </div>
<div className="mt-4 grid grid-cols-3 gap-3"> <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: 'Method', value: p.method || '—' },
{ label: 'Booking', value: p.booking?.bookingRef || '—' }, { label: 'Booking', value: p.booking?.bookingRef || '—' },
].map(({ label, value }) => ( ].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="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2"> <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-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> </div>
<Field label="Method" value={p.method} /> <Field label="Method" value={p.method} />
<Field label="Status" value={p.status} /> <Field label="Status" value={p.status} />

View File

@@ -10,6 +10,7 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general'); const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2'); const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20'); const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100'); const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
@@ -24,6 +25,7 @@ export default function SettingsPage() {
.then((data) => { .then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); 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?.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_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit); if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit); if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
@@ -39,6 +41,7 @@ export default function SettingsPage() {
await systemConfigApi.update({ await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes, seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours, hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit, throttle_auth_limit: throttleAuthLimit,
throttle_strict_limit: throttleStrictLimit, throttle_strict_limit: throttleStrictLimit,
throttle_default_limit: throttleDefaultLimit, 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. Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.
</p> </p>
</div> </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"> <div className="flex items-center gap-3">

View File

@@ -406,6 +406,7 @@ export default function TariffRatesPage() {
name="insuranceFeeMinor" name="insuranceFeeMinor"
className="input" className="input"
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'} defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
key={editingClass?.id ?? 'new-insurance'}
min="0" min="0"
step="0.01" step="0.01"
placeholder="e.g. 25.00" placeholder="e.g. 25.00"

View File

@@ -90,21 +90,21 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ {
title: 'Financial', title: 'Financial',
items: [ 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: '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: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, { 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: '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', title: 'Customer Services',
items: [ items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, // { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, // { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
] ]
}, },
@@ -126,7 +126,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ {
title: 'System', title: 'System',
items: [ 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: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin },
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },

View File

@@ -19,10 +19,13 @@ type BookingWithTicket = {
totalMinor?: number; totalMinor?: number;
createdAt?: string; createdAt?: string;
paymentMethod?: 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; barcodePayload?: string;
qrPayload?: string; qrPayload?: string;
}; }>;
}; };
export default function ConfirmationPage() { export default function ConfirmationPage() {
@@ -36,6 +39,14 @@ export default function ConfirmationPage() {
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(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>({ const { data: _booking } = useQuery<BookingWithTicket>({
queryKey: ['booking', bookingId], queryKey: ['booking', bookingId],
queryFn: async (): Promise<BookingWithTicket> => { queryFn: async (): Promise<BookingWithTicket> => {
@@ -142,9 +153,16 @@ export default function ConfirmationPage() {
seatClass: inboundSchedule.selectedSeatClassName, seatClass: inboundSchedule.selectedSeatClassName,
} : undefined; } : 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++) { for (let i = 0; i < passengers.length; i++) {
const p = passengers[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({ await generatePassengerVoucherPDF({
bookingRef: pnr, bookingRef: pnr,
@@ -163,9 +181,6 @@ export default function ConfirmationPage() {
currency: voucherCurrency, currency: voucherCurrency,
createdAt, 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) { } catch (error) {
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown 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> <h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
<div className="space-y-4"> <div className="space-y-4">
{passengers.map((passenger, index) => { {passengers.map((passenger, index) => {
const backendTicket = _booking?.ticket || null; // Match by name first (tickets aren't necessarily created/ordered the same
const ticketNumber = isConfirmed // way as this passengers array) — fall back to position if no name match.
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}` const backendTicket =
: null; _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 ( return (
<div key={index} className="card hover:shadow-lg transition-shadow"> <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 className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p> <p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p> <p className="font-semibold text-gray-900 dark:text-gray-100">
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
</p>
</div> </div>
<div> <div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p> <p className="text-gray-600 dark:text-gray-400">Date of Birth</p>

View File

@@ -4,14 +4,13 @@ import { Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Clock, Clock,
Users, Users,
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
Download, Download,
Share2,
Copy, Copy,
Check, Check,
CreditCard, CreditCard,
@@ -49,6 +48,14 @@ function BookingDetailContent() {
const [copiedPNR, setCopiedPNR] = useState(false); const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = 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({ const { data: booking, isLoading, error, refetch } = useQuery({
queryKey: ['booking-detail', bookingRef], queryKey: ['booking-detail', bookingRef],
queryFn: async () => { queryFn: async () => {
@@ -646,14 +653,6 @@ function BookingDetailContent() {
</> </>
)} )}
</button> </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> </div>

View File

@@ -72,6 +72,36 @@ function clearPendingFaydaIndex() {
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY); 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 // Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' { function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
const g = String(raw || '').trim().toUpperCase(); const g = String(raw || '').trim().toUpperCase();
@@ -85,11 +115,13 @@ function DobPickerModal({
onChange, onChange,
error, error,
passengerType = 'ADULT', passengerType = 'ADULT',
disabled = false,
}: { }: {
value: string; value: string;
onChange: (iso: string) => void; onChange: (iso: string) => void;
error?: string; error?: string;
passengerType?: 'ADULT' | 'CHILD'; passengerType?: 'ADULT' | 'CHILD';
disabled?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [manualMode, setManualMode] = useState(false); const [manualMode, setManualMode] = useState(false);
@@ -286,9 +318,10 @@ function DobPickerModal({
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={disabled}
className={`input-field w-full text-left flex items-center justify-between ${ className={`input-field w-full text-left flex items-center justify-between ${
error ? 'border-red-500' : '' 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'}> <span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
{displayValue || 'Select date of birth'} {displayValue || 'Select date of birth'}
@@ -470,12 +503,14 @@ function PhoneInput({
onInterimChange, onInterimChange,
onNormalized, onNormalized,
error, error,
disabled = false,
}: { }: {
nationality: string; nationality: string;
storedValue: string; storedValue: string;
onInterimChange: (full: string) => void; onInterimChange: (full: string) => void;
onNormalized: (full: string) => void; onNormalized: (full: string) => void;
error?: string; error?: string;
disabled?: boolean;
}) { }) {
const nat = getPhoneNat(nationality); const nat = getPhoneNat(nationality);
const preset = PHONE_PRESETS[nat]; const preset = PHONE_PRESETS[nat];
@@ -519,7 +554,10 @@ function PhoneInput({
onBlur={handleBlur} onBlur={handleBlur}
placeholder={preset.example} placeholder={preset.example}
autoComplete="tel" 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> </div>
{error ? ( {error ? (
@@ -565,6 +603,10 @@ const passengerSchema = z.object({
passportIssuingAuthority: z.string().optional(), passportIssuingAuthority: z.string().optional(),
faydaVerified: z.boolean().optional(), faydaVerified: z.boolean().optional(),
faydaSub: z.string().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(), formExpanded: z.boolean().optional(),
}).superRefine((data, ctx) => { }).superRefine((data, ctx) => {
if (data.gender !== 'Male' && data.gender !== 'Female') { if (data.gender !== 'Male' && data.gender !== 'Female') {
@@ -635,7 +677,7 @@ function createFormSchema(adultCount: number) {
type FormData = z.infer<ReturnType<typeof createFormSchema>>; type FormData = z.infer<ReturnType<typeof createFormSchema>>;
export default function PassengersPage() { function PassengersForm() {
const router = useRouter(); const router = useRouter();
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore(); const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore(); const { user, isAuthenticated, updateUser } = useAuthStore();
@@ -662,6 +704,11 @@ export default function PassengersPage() {
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
passengers: Array.from({ length: totalPassengers }, (_, i) => { 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]; const stored = storedPassengers[i];
if (stored?.name) { if (stored?.name) {
return { return {
@@ -700,7 +747,7 @@ export default function PassengersPage() {
formExpanded: i >= adultCount, 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. // 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 // 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 // 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)}` `/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
); );
if (response?.success && response?.data?.verified) { // apiClient already unwraps the { success, data } envelope, so `response` is the
const d = response.data; // verification result itself.
const d = response;
if (d?.verified) {
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin; const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
// A single Fayda identity can't be reused across two different passengers. // 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 (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub); if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
// Only fill in this passenger's own contact fields if they haven't entered them yet. // 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.email && !watch(`passengers.${targetIndex}.email`)) {
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true }); 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}.faydaVerified`, true);
setValue(`passengers.${targetIndex}.formExpanded`, true); setValue(`passengers.${targetIndex}.formExpanded`, true);
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' })); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; }); setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
if (targetIndex === 0 && isAuthenticated) {
updateUser({ fullName: d.fullName, faydaVerified: true });
}
} }
} else { } else {
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' })); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
@@ -824,6 +903,15 @@ export default function PassengersPage() {
useEffect(() => { useEffect(() => {
const populateForm = async () => { const populateForm = async () => {
if (!isInitialized) return; 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) { if (!isAuthenticated || !user?.id || !searchCriteria) {
setFormInitialized(true); setFormInitialized(true);
return; return;
@@ -864,16 +952,19 @@ export default function PassengersPage() {
const openFaydaVerification = async (index: number) => { const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// Only one passenger can verify at a time this keeps the status poll below // Only one passenger can verify at a time so the returning ?code&state is unambiguously
// (which has no passenger identifier of its own) unambiguous about who it belongs to. // applied to the passenger who started it.
if (verifyingIndex !== null) return; if (verifyingIndex !== null) return;
setVerifyingIndex(index); setVerifyingIndex(index);
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' })); setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; }); setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
// Persist which passenger this is for so it survives a full-page redirect/reload // Stash the verifying passenger index and a full snapshot of the in-progress form. Both live
// if the browser can't open a popup (e.g. some mobile browsers). // 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); setPendingFaydaIndex(index);
saveFaydaFormSnapshot({ passengers: watch('passengers'), createAccount: watch('createAccount') });
try { try {
const response: any = await apiClient.post('/fayda/verification/start', { const response: any = await apiClient.post('/fayda/verification/start', {
@@ -881,78 +972,16 @@ export default function PassengersPage() {
platform: 'WEB', platform: 'WEB',
saveToAccount: index === 0 && isAuthenticated, saveToAccount: index === 0 && isAuthenticated,
}); });
// Redirect the whole tab to eSignet. Fayda returns the browser to this same route
const authorizationUrl = response.authorizationUrl; // (FAYDA_WEB_REDIRECT_URI = <portal>/booking/passengers) with ?code&state, which the
const width = 600; // completion effect above picks up on mount.
const height = 700; window.location.href = response.authorizationUrl;
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);
} catch (error) { } catch (error) {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' })); setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' })); setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
setVerifyingIndex(null); setVerifyingIndex(null);
clearPendingFaydaIndex(); clearPendingFaydaIndex();
clearFaydaFormSnapshot();
} }
}; };
@@ -1025,12 +1054,8 @@ export default function PassengersPage() {
} }
}; };
useEffect(() => { // searchCriteria is guaranteed present here — the PassengersPage gate below only mounts this
if (!searchCriteria) { // component after the persisted booking store has rehydrated and confirmed a booking exists.
window.location.href = '/';
}
}, [searchCriteria, router]);
if (!searchCriteria) return null; if (!searchCriteria) return null;
if (!formInitialized || faydaCompleting) { if (!formInitialized || faydaCompleting) {
@@ -1070,6 +1095,13 @@ export default function PassengersPage() {
const isVerifyingThis = verifyingIndex === index; const isVerifyingThis = verifyingIndex === index;
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index; const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
const faydaError = faydaErrors[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 ( return (
<div key={field.id} className="card"> <div key={field.id} className="card">
@@ -1148,7 +1180,7 @@ export default function PassengersPage() {
{status === 'success' && ( {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"> <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"> <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> </p>
</div> </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> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input <input
{...register(`passengers.${index}.name`)} {...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" placeholder="Full name as per ID"
/> />
{errors.passengers?.[index]?.name && ( {errors.passengers?.[index]?.name && (
@@ -1181,20 +1214,29 @@ export default function PassengersPage() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message} error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
/> />
</div> </div>
{/* Gender */} {/* Gender */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select {isFaydaLocked ? (
{...register(`passengers.${index}.gender`)} <input
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`} value={passengers[index]?.gender || ''}
> readOnly
<option value="">Select gender</option> className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
<option value="Male">Male</option> />
<option value="Female">Female</option> ) : (
</select> <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 && ( {errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p> <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)} onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message} error={errors.passengers?.[index]?.phone?.message}
disabled={isPhoneLocked}
/> />
</div> </div>
@@ -1235,7 +1278,8 @@ export default function PassengersPage() {
<input <input
type="email" type="email"
{...register(`passengers.${index}.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" placeholder="email@example.com"
/> />
{errors.passengers?.[index]?.email && ( {errors.passengers?.[index]?.email && (
@@ -1270,20 +1314,29 @@ export default function PassengersPage() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message} error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
/> />
</div> </div>
{/* Gender */} {/* Gender */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select {isFaydaLocked ? (
{...register(`passengers.${index}.gender`)} <input
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`} value={passengers[index]?.gender || ''}
> readOnly
<option value="">Select gender</option> className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
<option value="Male">Male</option> />
<option value="Female">Female</option> ) : (
</select> <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 && ( {errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
)} )}
@@ -1324,7 +1377,8 @@ export default function PassengersPage() {
<input <input
type="email" type="email"
{...register(`passengers.${index}.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" placeholder="email@example.com"
/> />
{errors.passengers?.[index]?.email && ( {errors.passengers?.[index]?.email && (
@@ -1446,3 +1500,40 @@ export default function PassengersPage() {
</div> </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 />;
}

View File

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

View File

@@ -313,14 +313,18 @@ export default function ReviewPage() {
} }
// Build booking request for authenticated users // Build booking request for authenticated users
// For package bookings, free children (first child per adult, no seat assigned) // Package bookings only: free children (first child per adult) don't go through
// are excluded from the passengers array — the backend derives them from adultCount/childCount. // seat selection and have no seatId, so they're excluded here — the backend derives
const bookingPassengers = passengers.filter((p, i) => { // 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) { if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild; return !isFreePkgChild;
} }
return !(isChild(p) && isFirstChild(passengers, i)); return true;
}); });
bookingData = { bookingData = {
@@ -371,14 +375,18 @@ export default function ReviewPage() {
if (priceTierId) bookingData.priceTierId = priceTierId; if (priceTierId) bookingData.priceTierId = priceTierId;
} else { } else {
// For guests: send full passenger details array // For guests: send full passenger details array
// For package bookings, free children (first child per adult, no seat assigned) // Package bookings only: free children (first child per adult) don't go through
// are excluded from the passengers array — the backend derives them from adultCount/childCount. // seat selection and have no seatId, so they're excluded here — the backend derives
const guestBookingPassengers = passengers.filter((p, i) => { // 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) { if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild; return !isFreePkgChild;
} }
return !(isChild(p) && isFirstChild(passengers, i)); return true;
}); });
bookingData = { bookingData = {

View File

@@ -552,6 +552,17 @@ export default function SearchPage() {
"origin" | "destination" | null "origin" | "destination" | null
>(null); >(null);
const [hasInteracted, setHasInteracted] = useState(false); 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[]>(() => { const [recentStationIds, setRecentStationIds] = useState<string[]>(() => {
try { try {
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
@@ -689,6 +700,7 @@ export default function SearchPage() {
const onSubmit = (data: SearchForm) => { const onSubmit = (data: SearchForm) => {
setHasInteracted(true); setHasInteracted(true);
setIsSearching(true);
// Clear previous booking selections and search cache before starting a new search // Clear previous booking selections and search cache before starting a new search
clearBooking(); clearBooking();
setSearchCriteria(data); 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. // origin/destination/date errors, which is noisier than fixing things one step at a time.
const onInvalid = (formErrors: typeof errors) => { const onInvalid = (formErrors: typeof errors) => {
setHasInteracted(true); setHasInteracted(true);
setIsSearching(false);
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality"); const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
if (formErrors.nationality && !hasOtherErrors) { if (formErrors.nationality && !hasOtherErrors) {
setPassengerModalOpen(true); setPassengerModalOpen(true);
@@ -793,8 +806,12 @@ export default function SearchPage() {
/> />
)} )}
{/* ── 90vh hero with banner image ── */} {/* ── Hero with banner image (desktop only — mobile is content-driven, no
<section className="relative h-[94vh] min-h-[560px]"> 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 */} {/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div <div
@@ -831,18 +848,15 @@ export default function SearchPage() {
etc.) are portaled to <body> — see ModernDatePicker — so they aren't etc.) are portaled to <body> — see ModernDatePicker — so they aren't
capped by this wrapper's own stacking context. ── */} capped by this wrapper's own stacking context. ── */}
<div <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} ref={widgetRef}
> >
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */} {/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
<div className="md:hidden mb-3"> <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? Where are you headed today?
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Book your train journey across East Africa
</p>
</div> </div>
<form onSubmit={handleSubmit(onSubmit, onInvalid)}> <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"> <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>
</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="flex flex-col gap-3 md:hidden">
<div className="space-y-1.5"> <div className="grid grid-cols-2 gap-3">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <div className="space-y-1.5 min-w-0">
From <div className="h-5 flex items-center">
</label> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
<button From
type="button" </label>
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> </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 <button
type="button" type="button"
onClick={handleSwap} onClick={() => {
disabled={!originId || !destId} setHasInteracted(true);
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30" window.scrollTo({
> top: 0,
<ArrowLeftRight behavior: "instant" as ScrollBehavior,
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`} });
/> setStationModal("origin");
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");
}} }}
minDate={new Date()} className="w-full"
placeholder="Select date" >
error={!!errors.departureDate} <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> </div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div> </div>
{tripType === "ROUND_TRIP" && ( <div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
<div className="space-y-1.5"> <div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date Date
</label> </label>
<div> <div>
<ModernDatePicker <ModernDatePicker
value={ value={
returnDate departureDate
? new Date(returnDate + "T00:00:00") ? new Date(departureDate + "T00:00:00")
: undefined : undefined
} }
onChange={(date) => { onChange={(date) => {
setValue( setValue(
"returnDate", "departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
); );
trigger("returnDate"); trigger("departureDate");
}} }}
minDate={ minDate={new Date()}
departureDate placeholder="Departure date"
? new Date(departureDate + "T00:00:00") error={!!errors.departureDate}
: new Date()
}
placeholder="Select return date"
error={!!errors.returnDate}
/> />
</div> </div>
{errors.returnDate && ( {errors.departureDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.returnDate.message} {errors.departureDate.message}
</p> </p>
)} )}
</div> </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 */} {/* Pax + Nationality combined trigger */}
<button <button
type="button" type="button"
@@ -1066,11 +1088,20 @@ export default function SearchPage() {
)} )}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || isSearching}
className="btn-primary w-full text-sm" className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<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> </button>
</div> </div>
@@ -1207,11 +1238,20 @@ export default function SearchPage() {
{/* Search */} {/* Search */}
<button <button
type="submit" type="submit"
disabled={isLoading} 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-50" className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<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> </button>
</div> </div>
) : ( ) : (
@@ -1282,7 +1322,7 @@ export default function SearchPage() {
trigger("returnDate"); trigger("returnDate");
}} }}
minDate={new Date()} minDate={new Date()}
placeholder="Select date" placeholder="Departure date"
/> />
{errors.departureDate && ( {errors.departureDate && (
<p className="text-xs text-red-500">{errors.departureDate.message}</p> <p className="text-xs text-red-500">{errors.departureDate.message}</p>
@@ -1298,7 +1338,7 @@ export default function SearchPage() {
trigger("returnDate"); trigger("returnDate");
}} }}
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
placeholder="Select date" placeholder="Return date"
/> />
{errors.returnDate && ( {errors.returnDate && (
<p className="text-xs text-red-500">{errors.returnDate.message}</p> <p className="text-xs text-red-500">{errors.returnDate.message}</p>
@@ -1328,11 +1368,20 @@ export default function SearchPage() {
{/* Search */} {/* Search */}
<button <button
type="submit" type="submit"
disabled={isLoading} 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-50" className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<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> </button>
</div> </div>
)} )}

View File

@@ -275,8 +275,8 @@ export default function Contact() {
}; };
const contactInfo = [ const contactInfo = [
{ icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' },
{ icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, { 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: '#' }, { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' },
]; ];

View File

@@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
answer: 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.', '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?', question: 'What are the passenger age categories?',
answer: 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?', question: 'How is a child\'s age determined?',
answer: 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.', '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: 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.', '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?', question: 'What payment methods are accepted?',
answer: answer:
'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.', 'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. 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 57 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.',
}, },
{ {
question: 'Is my payment information secure?', question: 'Is my payment information secure?',
answer: 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: answer:
'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.', '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.',
},
], ],
}, },
]; ];

View File

@@ -14,12 +14,18 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import ChangePasswordModal from '@/components/ChangePasswordModal';
import { BOOKING_STEPS } from '@/components/ProgressIndicator'; 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 // Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
// get a vertical step list instead of the standard nav highlighting. // get a vertical step list instead of the standard nav highlighting.
const BOOKING_STEP_MAP: Record<string, string> = { const BOOKING_STEP_MAP: Record<string, string> = {
@@ -203,10 +209,12 @@ export default function AppSidebar() {
)} )}
</div> </div>
<ChangePasswordModal {showChangePassword && (
isOpen={showChangePassword} <ChangePasswordModal
onClose={() => setShowChangePassword(false)} isOpen={showChangePassword}
/> onClose={() => setShowChangePassword(false)}
/>
)}
</aside> </aside>
); );
} }

View File

@@ -286,14 +286,14 @@ export default function ModernDatePicker({
<button <button
type="button" type="button"
onClick={() => setIsOpen(true)} 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 error
? 'border-red-400 hover:border-red-400' ? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : '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'}`}> <span className={`text-sm whitespace-nowrap truncate ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder} {value ? format(value, 'MMM d, yyyy') : placeholder}
</span> </span>
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" /> <CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</button> </button>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Moon, Sun, Monitor } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useTheme } from './ThemeProvider'; import { useTheme } from './ThemeProvider';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -12,27 +12,11 @@ export default function ThemeToggle() {
setMounted(true); setMounted(true);
}, []); }, []);
const cycleTheme = () => { const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getIcon = () => { const getIcon = () => theme === 'dark' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />;
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 getLabel = () => { const getLabel = () => theme === 'dark' ? 'Dark' : 'Light';
if (theme === 'light') return 'Light';
if (theme === 'dark') return 'Dark';
return 'System';
};
// Prevent hydration mismatch by not rendering until mounted // Prevent hydration mismatch by not rendering until mounted
if (!mounted) { if (!mounted) {

View File

@@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
// ─── header ──────────────────────────────────────────────────────────────── // ─── 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> { async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
const pageWidth = doc.internal.pageSize.getWidth(); const pageWidth = doc.internal.pageSize.getWidth();
const bandHeight = 24; const bandHeight = 24;
@@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
doc.rect(0, 0, pageWidth, bandHeight, 'F'); doc.rect(0, 0, pageWidth, bandHeight, 'F');
try { try {
const logoImg = await fetch('/edr-logo.png'); const { dataUrl: logoDataUrl, width, height } = await loadLogo();
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 logoH = 13; const logoH = 13;
const logoW = (img.width / img.height) * logoH; const logoW = (width / height) * logoH;
const textX = margin + logoW + 5; const textX = margin + logoW + 5;
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH); doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
@@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); 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.setFontSize(6.5);
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
} }
// ─── public API ────────────────────────────────────────────────────────────── // ─── public API ──────────────────────────────────────────────────────────────
/** Generates and downloads one PDF voucher for a single passenger. */ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise<void> {
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth(); const pageW = doc.internal.pageSize.getWidth();
const margin = PAGE_MARGIN; 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); y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
drawInstructions(doc, y, margin, pageW); drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt); 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, ''); const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
doc.save(`Voucher_${safeName}.pdf`); doc.save(`Voucher_${safeName}.pdf`);
@@ -404,14 +423,28 @@ interface VoucherData {
currency: string; currency: string;
bookingType: string; bookingType: string;
createdAt: 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> => { 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++) { for (let i = 0; i < booking.passengers.length; i++) {
const p = booking.passengers[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({ await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef, bookingRef: booking.bookingRef,
ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, ticketNumber,
passengerName: p.fullName, passengerName: p.fullName,
seatNumber: p.seat?.number, seatNumber: p.seat?.number,
status: booking.status, status: booking.status,
@@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
currency: booking.currency, currency: booking.currency,
createdAt: booking.createdAt, 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));
} }
}; };