feat(WIP): booking payment integration

This commit is contained in:
ghost2023
2026-06-06 12:07:18 +03:00
parent e152e7e986
commit bc5e6f900c
4 changed files with 124 additions and 18 deletions

View File

@@ -64,7 +64,6 @@ const App = () => {
if (user && location.pathname === "/") navigate("/portal");
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
else if (customer && !isInProtectedRoutes) return navigate("/portal");
}, [user, location, customer]);
if (isPending) {
@@ -109,7 +108,10 @@ const App = () => {
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/bookings/:id/contract" element={<BookingContractPage />} />
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />

View File

@@ -436,20 +436,6 @@ function DraftBookingView({
</div>
)}
{pricingQuery.isError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p className="font-semibold">Pricing failed</p>
<p className="mt-1 text-destructive/80">
{pricingQuery.error instanceof Error
? pricingQuery.error.message
: "An unexpected error occurred."}
</p>
</div>
</div>
)}
{uploadMutation.isError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
@@ -776,11 +762,28 @@ function DraftBookingView({
function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const payMutation = useMutation({
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
onSuccess: (data) => {
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
});
const normalizedStatus = booking.status as keyof typeof STATUS_MAP;
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
const currentStageIndex = statusConfig.stage;
const pricing = booking.pricingBreakdown;
const uploadedCodes = useMemo(
() => new Set(booking.files?.map((f) => f.code) ?? []),
[booking.files],
);
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex flex-col gap-8">
@@ -797,7 +800,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="size-6" />
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black tracking-tight text-foreground">
{booking.reference}
@@ -814,11 +817,100 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
</span>
</div>
</div>
{normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && (
<Button
type="button"
onClick={() => payMutation.mutate()}
disabled={payMutation.isPending}
>
{payMutation.isPending ? (
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
) : (
<CreditCard className="mr-1 h-4 w-4" />
)}
{payMutation.isPending ? "Processing..." : "Pay Now"}
</Button>
)}
</div>
</CardHeader>
</Card>
{renderContractCard(booking, navigate)}
{renderContractCard(booking, navigate, payMutation)}
{pricing && (
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<DollarSign className="size-4 text-primary" />
Pricing Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-left text-xs">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="px-4 py-2 font-semibold">Description</th>
<th className="px-4 py-2 font-semibold text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y">
{pricing.lineItems.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-foreground">{item.description}</td>
<td className="px-4 py-2 text-right font-medium text-foreground">
{item.amount.toLocaleString()} {item.currency}
</td>
</tr>
))}
<tr className="bg-primary/5 font-bold">
<td className="px-4 py-2 text-foreground">Total Estimated Cost</td>
<td className="px-4 py-2 text-right text-foreground">
{pricing.totalAmount.toLocaleString()} {pricing.currency}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{booking.files && booking.files.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-primary" />
Uploaded Documents ({booking.files.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{booking.files.map((file) => (
<a
key={file.id}
href={file.signedUrl ?? file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-lg border border-border p-3 transition hover:border-primary/40"
>
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10">
<FileText className="size-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{file.code.replace(/_/g, " ")}
</p>
</div>
</a>
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
@@ -1174,6 +1266,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
function renderContractCard(
booking: Freight.IBooking,
navigate: ReturnType<typeof useNavigate>,
payMutation: { mutate: () => void; isPending: boolean },
) {
const s = booking.status;
if (

View File

@@ -173,6 +173,12 @@ export const api = {
>("bookings", "uploadDocuments", ({ id, files }) =>
bookingsService.uploadDocuments(id, files),
),
pay: endpoint<{ id: string }, { redirectUrl: string }>(
"bookings",
"pay",
({ id }) => bookingsService.pay(id),
),
},
consignments: {

View File

@@ -134,6 +134,11 @@ export const bookingsService = {
return data;
},
pay: async (id: string): Promise<{ redirectUrl: string }> => {
const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
return data.data ?? data;
},
signContract: async (
id: string,
payload: SignContractPayload,