mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding ticket generation and booking for staff employees logic
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ReservationPayFailedPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your payment could not be completed. Please try again.
|
||||
</p>
|
||||
<Link href={`/reserve/pay/${token}`} className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||
Try again
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import {
|
||||
Loader2,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Wallet,
|
||||
Landmark,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const getIconForMethod = (methodId: string) => {
|
||||
if (methodId.includes("CARD")) return CreditCard;
|
||||
if (methodId.includes("WALLET")) return Wallet;
|
||||
if (methodId.includes("CAC")) return Landmark;
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone pay-by-link page for a booking issued via the backoffice's "reserve seat →
|
||||
* issue booking (passenger)" flow — no portal login/session required, unlike the normal
|
||||
* /booking/payment page which depends on client-side booking-store state populated during
|
||||
* a live search→seats→review session. Cloned from /pay-balance/[token] (same pattern:
|
||||
* resolve-by-token, method picker, single pay button) but pointed at a booking instead of a
|
||||
* supplementary charge, and reusing the SAME already-public booking-payment endpoints
|
||||
* (/payments/initiate, /payments/methods) the normal payment page calls — no new payment
|
||||
* mechanics, just a session-free entry point.
|
||||
*/
|
||||
export default function ReservationPayPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const router = useRouter();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const { data: booking, isLoading: loadingBooking, error: bookingError } = useQuery({
|
||||
queryKey: ["reservation-booking", token],
|
||||
queryFn: () => apiClient.get<any>(`/bookings/pay/${token}`),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ["paymentMethods"],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<PaymentMethod[]>("/payments/methods");
|
||||
return Array.isArray(res) ? res : [];
|
||||
},
|
||||
enabled: !!booking,
|
||||
});
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: string) =>
|
||||
apiClient.post<any>("/payments/initiate", {
|
||||
bookingId: booking.id,
|
||||
method,
|
||||
platform: "web",
|
||||
}),
|
||||
onSuccess: (data: any) => {
|
||||
if (data?.clientAction?.type === "REDIRECT") {
|
||||
window.location.href = data.clientAction.url;
|
||||
return;
|
||||
}
|
||||
router.push(`/reserve/pay/${token}/success`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setPaymentError(
|
||||
err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."
|
||||
);
|
||||
setIsProcessing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePay = () => {
|
||||
if (!selectedMethod) return;
|
||||
setIsProcessing(true);
|
||||
setPaymentError(null);
|
||||
payMutation.mutate(selectedMethod);
|
||||
};
|
||||
|
||||
if (loadingBooking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookingError || !booking) {
|
||||
const msg = (bookingError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currency = booking.displayCurrency ?? booking.currency ?? "ETB";
|
||||
const amountMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const amountDisplay = (amountMinor / 100).toFixed(2);
|
||||
const seat = booking.seats?.[0];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Complete your booking</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{booking.bookingRef}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Route</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{booking.schedule?.origin?.name} → {booking.schedule?.destination?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Departure</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
{seat && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Seat</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{seat.seatNumber} {seat.coach ? `(Coach ${seat.coach})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||
{loadingMethods ? (
|
||||
<div className="flex items-center justify-center py-6 gap-2">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
|
||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
|
||||
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm text-center">⚠️ {paymentError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ReservationPaySuccessPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment successful</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your booking is confirmed. Your ticket has been sent to you.
|
||||
</p>
|
||||
<Link href="/" className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user