implement PayNowButton component and integrate payment functionality in booking rows

This commit is contained in:
Marshal
2026-06-17 13:50:57 +00:00
parent 7eb6228d31
commit c89f7dcc11
5 changed files with 181 additions and 52 deletions

View File

@@ -721,27 +721,37 @@ export class TrainSchedulingService {
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -802,7 +820,7 @@ export class TrainSchedulingService {
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,

View File

@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : (
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack>
</Group>
</Box>

View File

@@ -21,7 +21,6 @@ import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
CreditCard,
FileEdit,
LayoutList,
MoreVertical,
@@ -34,6 +33,7 @@ import {
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
@@ -165,14 +165,13 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
status,
id,
booking,
onNavigate,
}: {
status: string;
id: string;
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
@@ -189,20 +188,8 @@ function PrimaryAction({
</Button>
);
}
if (status === "SELECTED_FOR_BATCH") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
leftSection={<CreditCard size={14} />}
color="edr-green"
onClick={go}
>
Pay now
</Button>
);
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
@@ -501,7 +488,7 @@ export default function MyBookings() {
Track
</Button>
)}
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">

View File

@@ -0,0 +1,61 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { Freight } from "@edr/types";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
return (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /payments/initiate, and redirects the browser to the provider (or the
* fallback checkout page). Reused by the booking detail page, the booking list,
* and the home page so "Pay now" behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId, method }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId, method });
window.location.href = redirectUrl;
},
});
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
setModalOpen(false);
mutation.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
};
}