mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
feat(WIP): booking payment integration
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user