From 1861d45f1e166a598467042fb811b76ece36fc00 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 16 Jun 2026 20:38:48 +0300 Subject: [PATCH] feat: add the payment to booking page --- .../src/pages/bookings/NewBookingPage.tsx | 159 +++++++++++++++--- .../new-booking-form/step8-review.tsx | 98 ++++++++++- 2 files changed, 233 insertions(+), 24 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 696381917..6ccf34362 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,13 +1,27 @@ import { api } from "@/services/api"; -import type { CreateBookingPayload } from "@/services/bookings.service"; +import type { + CreateBookingPayload, + GeneratePriceResponse, +} from "@/services/bookings.service"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Alert, Box, Button, Group, Text, Title } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertCircle, Check, ChevronLeft, ChevronRight, Send } from "lucide-react"; +import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, XCircle } from "lucide-react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; +import type { Freight } from "@/types"; import { BookingFormInputValues, STEPS, @@ -97,28 +111,55 @@ export default function NewBookingPage() { }, }); - const submitMutation = useMutation({ + const createAndPriceMutation = useMutation({ mutationFn: async (payload: CreateBookingPayload) => { const booking = await api.bookings.create.call(payload); const documents = form.getValues("documents") ?? {}; - const hasDocuments = Object.values(documents).some((value) => + const hasDocs = Object.values(documents).some((value) => Array.isArray(value) ? value.length > 0 : Boolean(value), ); - if (hasDocuments) { + if (hasDocs) { await api.bookings.uploadDocuments.call({ id: booking.id, files: documents, }); } - await api.bookings.submit.call({ id: booking.id }); + const pricing = await api.bookings.generatePrice.call({ id: booking.id }); - return booking; + return { bookingId: booking.id, pricing }; }, - onSuccess: (booking) => { + onSuccess: ({ bookingId, pricing }) => { + setPriceBookingId(bookingId); + setPricingData(pricing); + setPricingPhase("ready"); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - navigate(`/bookings/${booking.id}`); + }, + onError: () => { + setPricingPhase("idle"); + }, + }); + + const confirmMutation = useMutation({ + mutationFn: async () => { + if (!priceBookingId) throw new Error("No booking to confirm"); + await api.bookings.submit.call({ id: priceBookingId }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + navigate(`/bookings/${priceBookingId}`); + }, + }); + + const abortMutation = useMutation({ + mutationFn: async (reason: string) => { + if (!priceBookingId) throw new Error("No booking to abort"); + await api.bookings.cancel.call({ id: priceBookingId, reason }); + }, + onSuccess: () => { + setCancelDialogOpen(false); + navigate("/bookings"); }, }); @@ -150,6 +191,12 @@ export default function NewBookingPage() { [docValues], ); + const [pricingPhase, setPricingPhase] = useState<"idle" | "generating" | "ready">("idle"); + const [pricingData, setPricingData] = useState(null); + const [priceBookingId, setPriceBookingId] = useState(null); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + async function handleContinue() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; @@ -265,10 +312,11 @@ export default function NewBookingPage() { } }); - const handleFullSubmit = form.handleSubmit((data) => { + const handleGeneratePrice = form.handleSubmit((data) => { try { const apiPayload = buildApiPayload(data); - submitMutation.mutate(apiPayload); + setPricingPhase("generating"); + createAndPriceMutation.mutate(apiPayload); } catch { // validation error already handled } @@ -343,7 +391,7 @@ export default function NewBookingPage() { )} - {submitMutation.isError && ( + {createAndPriceMutation.isError && ( } @@ -351,11 +399,11 @@ export default function NewBookingPage() { mb="lg" > - Failed to submit + Failed to generate price estimate - {submitMutation.error instanceof Error - ? submitMutation.error.message + {createAndPriceMutation.error instanceof Error + ? createAndPriceMutation.error.message : "An unexpected error occurred. Please try again."} @@ -392,6 +440,15 @@ export default function NewBookingPage() { setStep={setStep} direction={direction!} referenceData={referenceData} + pricingPhase={pricingPhase} + pricingData={pricingData} + onConfirm={() => confirmMutation.mutate()} + onContinueLater={ + priceBookingId ? () => navigate(`/bookings/${priceBookingId}`) : undefined + } + onAbort={() => setCancelDialogOpen(true)} + confirmPending={confirmMutation.isPending} + abortPending={abortMutation.isPending} /> )} @@ -432,7 +489,7 @@ export default function NewBookingPage() { > Continue - ) : ( + ) : pricingPhase === "idle" ? ( )} - )} + ) : pricingPhase === "generating" ? ( + + ) : null} - {/* */} + + setCancelDialogOpen(false)} + title={Abort booking} + radius="lg" + centered + > + + + Are you sure you want to abort this booking? This action cannot be + undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 7d14dd28e..bbfd1cc34 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -1,5 +1,17 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core"; +import { + Box, + Button, + Card, + Checkbox, + Divider, + Group, + Loader, + SimpleGrid, + Text, + Textarea, +} from "@mantine/core"; +import { Check, Send, XCircle } from "lucide-react"; import { BookingFormInputValues, BOOKING_DOCS_SETTING, @@ -8,6 +20,7 @@ import { } from "./schema"; import { StepHeader } from "./shared"; import type { Freight } from "@/types"; +import type { GeneratePriceResponse } from "@/services/bookings.service"; type BookingForm = UseFormReturn< BookingFormInputValues, @@ -20,11 +33,25 @@ export function Step8Review({ setStep, direction, referenceData, + pricingPhase = "idle", + pricingData, + onConfirm, + onContinueLater, + onAbort, + confirmPending = false, + abortPending = false, }: { form: BookingForm; setStep: (step: number) => void; direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; + pricingPhase?: "idle" | "generating" | "ready"; + pricingData?: GeneratePriceResponse | null; + onConfirm?: () => void; + onContinueLater?: () => void; + onAbort?: () => void; + confirmPending?: boolean; + abortPending?: boolean; }) { const values = form.watch(); const errors = form.formState.errors; @@ -272,6 +299,75 @@ export function Step8Review({ /> )} /> + + {pricingPhase === "generating" && ( + + + + + Generating price estimate… + + + + )} + + {pricingPhase === "ready" && pricingData && ( + + + Price Estimate + + {pricingData.lineItems.map((item) => ( + + {item.description} + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + + Total + + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + + + {pricingData.warnings.length > 0 && ( + + {pricingData.warnings.join(", ")} + + )} + + + + + + + )} ); }