Adding ticket generation and booking for staff employees logic

This commit is contained in:
Muluhabt
2026-07-25 11:20:47 +03:00
parent 7320c359d1
commit 2a0fd9bda5
29 changed files with 2659 additions and 74 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}