feat: add the payment to booking page

This commit is contained in:
ghost2023
2026-06-16 20:38:48 +03:00
parent c73780712d
commit 1861d45f1e
2 changed files with 233 additions and 24 deletions

View File

@@ -1,13 +1,27 @@
import { api } from "@/services/api"; 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 { 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 { 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 { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { Freight } from "@/types";
import { import {
BookingFormInputValues, BookingFormInputValues,
STEPS, STEPS,
@@ -97,28 +111,55 @@ export default function NewBookingPage() {
}, },
}); });
const submitMutation = useMutation({ const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => { mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload); const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {}; 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), Array.isArray(value) ? value.length > 0 : Boolean(value),
); );
if (hasDocuments) { if (hasDocs) {
await api.bookings.uploadDocuments.call({ await api.bookings.uploadDocuments.call({
id: booking.id, id: booking.id,
files: documents, 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() }); 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], [docValues],
); );
const [pricingPhase, setPricingPhase] = useState<"idle" | "generating" | "ready">("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; if (!valid) return;
@@ -265,10 +312,11 @@ export default function NewBookingPage() {
} }
}); });
const handleFullSubmit = form.handleSubmit((data) => { const handleGeneratePrice = form.handleSubmit((data) => {
try { try {
const apiPayload = buildApiPayload(data); const apiPayload = buildApiPayload(data);
submitMutation.mutate(apiPayload); setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
} catch { } catch {
// validation error already handled // validation error already handled
} }
@@ -343,7 +391,7 @@ export default function NewBookingPage() {
</Alert> </Alert>
)} )}
{submitMutation.isError && ( {createAndPriceMutation.isError && (
<Alert <Alert
color="red" color="red"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
@@ -351,11 +399,11 @@ export default function NewBookingPage() {
mb="lg" mb="lg"
> >
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Failed to submit Failed to generate price estimate
</Text> </Text>
<Text size="sm" mt={4} c="red.7"> <Text size="sm" mt={4} c="red.7">
{submitMutation.error instanceof Error {createAndPriceMutation.error instanceof Error
? submitMutation.error.message ? createAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."} : "An unexpected error occurred. Please try again."}
</Text> </Text>
</Alert> </Alert>
@@ -392,6 +440,15 @@ export default function NewBookingPage() {
setStep={setStep} setStep={setStep}
direction={direction!} direction={direction!}
referenceData={referenceData} referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId ? () => navigate(`/bookings/${priceBookingId}`) : undefined
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/> />
)} )}
</Box> </Box>
@@ -432,7 +489,7 @@ export default function NewBookingPage() {
> >
Continue Continue
</Button> </Button>
) : ( ) : pricingPhase === "idle" ? (
<Group> <Group>
<Button <Button
type="submit" type="submit"
@@ -452,21 +509,77 @@ export default function NewBookingPage() {
type="button" type="button"
color="edr-green" color="edr-green"
radius="md" radius="md"
loading={submitMutation.isPending} loading={createAndPriceMutation.isPending}
leftSection={ leftSection={
submitMutation.isPending ? undefined : <Send size={16} /> createAndPriceMutation.isPending ? undefined : <Send size={16} />
} }
onClick={() => handleFullSubmit()} onClick={() => handleGeneratePrice()}
> >
{submitMutation.isPending ? "Submitting..." : "Submit"} {createAndPriceMutation.isPending ? "Generating price…" : "Submit"}
</Button> </Button>
)} )}
</Group> </Group>
)} ) : pricingPhase === "generating" ? (
<Button
type="button"
color="edr-green"
radius="md"
loading
>
Generating price estimate
</Button>
) : null}
</Group> </Group>
</Box> </Box>
</form> </form>
{/* <DevTool control={form.control} /> */}
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Abort booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to abort this booking? This action cannot be
undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep editing
</Button>
<Button
color="red"
radius="md"
onClick={() =>
abortMutation.mutate(
cancelReason.trim() || "Aborted by customer",
)
}
disabled={abortMutation.isPending}
loading={abortMutation.isPending}
leftSection={
!abortMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, abort
</Button>
</Group>
</Stack>
</Modal>
</Box> </Box>
); );
} }

View File

@@ -1,5 +1,17 @@
import { Controller, type UseFormReturn } from "react-hook-form"; 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 { import {
BookingFormInputValues, BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
@@ -8,6 +20,7 @@ import {
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; import { StepHeader } from "./shared";
import type { Freight } from "@/types"; import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -20,11 +33,25 @@ export function Step8Review({
setStep, setStep,
direction, direction,
referenceData, referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection; direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData; 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 values = form.watch();
const errors = form.formState.errors; const errors = form.formState.errors;
@@ -272,6 +299,75 @@ export function Step8Review({
/> />
)} )}
/> />
{pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg">
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb="sm">
Price Estimate
</Text>
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={4}>
<Text size="sm">{item.description}</Text>
<Text size="sm" fw={500}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
<Divider my="sm" />
<Group justify="space-between" py={2}>
<Text fw={700}>Total</Text>
<Text fw={700}>
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="sm">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group mt="lg">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
{abortPending ? "Aborting…" : "Abort"}
</Button>
</Group>
</Card>
)}
</div> </div>
); );
} }