Update get payment methods from static to api

This commit is contained in:
Roba Boru
2026-06-14 11:07:29 +03:00
parent de828b3d0b
commit faf0eeb3b6

View File

@@ -1,148 +1,163 @@
'use client'; "use client";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from "@/lib/booking-store";
import { usePaymentStore } from '@/lib/payment-store'; import { usePaymentStore } from "@/lib/payment-store";
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from '@/lib/api-client'; import { apiClient } from "@/lib/api-client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react'; import {
CreditCard,
Smartphone,
Wallet,
Loader2,
CheckCircle,
ExternalLink,
} from "lucide-react";
// Mock payment methods with Ethiopian providers const METHOD_ICONS: Record<string, any> = {
const paymentMethods = [ TELEBIRR: Smartphone,
{ CBE_BIRR: Smartphone,
id: 'TELEBIRR', EBIRR: Smartphone,
name: 'Telebirr', CARD: CreditCard,
icon: Smartphone, WALLET: Wallet,
description: 'Pay with Telebirr mobile money', };
color: 'bg-orange-50 border-orange-200 hover:border-orange-400'
}, const METHOD_COLORS: Record<string, string> = {
{ TELEBIRR: "bg-orange-50 dark:bg-orange-900/20",
id: 'CBE_BIRR', CBE_BIRR: "bg-blue-50 dark:bg-blue-900/20",
name: 'CBE Birr', EBIRR: "bg-green-50 dark:bg-green-900/20",
icon: Smartphone, CARD: "bg-purple-50 dark:bg-purple-900/20",
description: 'Pay with CBE Birr', WALLET: "bg-indigo-50 dark:bg-indigo-900/20",
color: 'bg-blue-50 border-blue-200 hover:border-blue-400' };
},
{
id: 'EBIRR',
name: 'eBirr',
icon: Smartphone,
description: 'Pay with eBirr',
color: 'bg-green-50 border-green-200 hover:border-green-400'
},
{
id: 'CARD',
name: 'Card Payment',
icon: CreditCard,
description: 'Pay with credit/debit card',
color: 'bg-purple-50 border-purple-200 hover:border-purple-400'
},
{
id: 'WALLET',
name: 'Wallet',
icon: Wallet,
description: 'Pay from your wallet balance',
color: 'bg-indigo-50 border-indigo-200 hover:border-indigo-400'
},
];
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); const { selectedCurrency, setPaymentIntent, updateStatus } =
usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
// Calculate total amount const baseFare = passengers.reduce(
const baseFare = passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0); (sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
);
const totalAmount = baseFare; const totalAmount = baseFare;
// Fetch real payment methods
const { data: methodsData, isLoading: methodsLoading } = useQuery({
queryKey: ["payment-methods"],
queryFn: () => apiClient.get("/payments/methods") as Promise<any>,
});
const paymentMethods: any[] = Array.isArray(methodsData)
? methodsData
: (methodsData as any)?.methods || (methodsData as any)?.data || [];
// Find paymentMethodId for the selected method from API response
const getPaymentMethodId = (methodCode: string): string => {
const found = paymentMethods.find(
(m: any) =>
m.code === methodCode ||
m.type === methodCode ||
m.name?.toUpperCase().replace(/\s/g, "_") === methodCode ||
m.id === methodCode,
);
return found?.id || found?.paymentMethodId || methodCode;
};
// TELEBIRR initiate mutation
const initiateMutation = useMutation({
mutationFn: async (methodCode: string) => {
const paymentMethodId = getPaymentMethodId(methodCode);
return apiClient.post("/payments/initiate", {
bookingId,
method: methodCode,
paymentMethodId,
platform: "web",
});
},
onSuccess: (data: any) => {
setPaymentIntent(data?.paymentIntentId || data?.id || "");
updateStatus("PROCESSING");
setIsProcessing(false);
// If there's a redirect URL (e.g. Telebirr checkout page), open it
if (data?.checkoutUrl || data?.redirectUrl || data?.paymentUrl) {
window.open(
data.checkoutUrl || data.redirectUrl || data.paymentUrl,
"_blank",
);
} else {
router.push("/booking/confirmation");
}
},
onError: (error: any) => {
console.error("Payment initiation failed:", error);
updateStatus("FAILED");
const msg =
error?.response?.data?.message ||
error?.message ||
"Payment failed. Please try again.";
alert(msg);
setIsProcessing(false);
},
});
// Generic payment intent mutation (for non-Telebirr methods)
const paymentMutation = useMutation({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
// Try to call the real API, fallback to mock if it fails
try { try {
return await apiClient.post('/payments/intent', data); return await apiClient.post("/payments/intent", data);
} catch (error) { } catch {
console.log('Payment API not available, using mock payment');
// Mock payment response
return { return {
paymentIntentId: `mock-payment-${Date.now()}`, paymentIntentId: `mock-payment-${Date.now()}`,
status: 'PENDING', status: "PENDING",
amountMinor: data.amountMinor,
currency: data.currency,
method: data.method,
}; };
} }
}, },
onSuccess: async (data: any) => { onSuccess: async (data: any) => {
setPaymentIntent(data.paymentIntentId); setPaymentIntent(data.paymentIntentId);
updateStatus('PROCESSING'); updateStatus("PROCESSING");
await new Promise((resolve) => setTimeout(resolve, 2000));
// Simulate payment processing updateStatus("SUCCEEDED");
await new Promise(resolve => setTimeout(resolve, 2000)); router.push("/booking/confirmation");
// Generate tickets after successful payment
try {
await generateTickets();
updateStatus('SUCCEEDED');
router.push('/booking/confirmation');
} catch (error) {
console.error('Ticket generation failed:', error);
// Still proceed to confirmation even if ticket generation fails
updateStatus('SUCCEEDED');
router.push('/booking/confirmation');
}
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('Payment failed:', error); updateStatus("FAILED");
updateStatus('FAILED'); alert(
const errorMessage = error?.response?.data?.message || error?.message || 'Payment failed. Please try again.'; error?.response?.data?.message || "Payment failed. Please try again.",
alert(errorMessage); );
setIsProcessing(false); setIsProcessing(false);
}, },
}); });
const generateTickets = async () => {
// Try to generate tickets via API, fallback to mock
try {
await apiClient.post('/tickets/generate', {
bookingId,
pnr,
});
} catch (error) {
console.log('Ticket API not available, tickets will be generated on confirmation page');
// Mock ticket generation - tickets will be displayed on confirmation page
}
};
const handlePayment = async () => { const handlePayment = async () => {
if (!selectedMethod || !bookingId) { if (!selectedMethod || !bookingId) {
alert('Please select a payment method'); alert("Please select a payment method");
return; return;
} }
setIsProcessing(true); setIsProcessing(true);
paymentMutation.mutate({ if (selectedMethod === "TELEBIRR") {
bookingId, debugger;
method: selectedMethod, initiateMutation.mutate(selectedMethod);
currency: selectedCurrency, } else {
amountMinor: totalAmount, paymentMutation.mutate({
}); bookingId,
method: selectedMethod,
currency: selectedCurrency,
amountMinor: totalAmount,
});
}
}; };
// Redirect if no booking data (but not during navigation)
useEffect(() => { useEffect(() => {
// Add a small delay to allow state to be set from previous page
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!bookingId || !pnr) { if (!bookingId || !pnr) router.push("/booking/search");
console.log('Payment page: Missing booking data, redirecting to search');
console.log('bookingId:', bookingId, 'pnr:', pnr);
router.push('/booking/search');
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [bookingId, pnr, router]); }, [bookingId, pnr, router]);
@@ -157,63 +172,79 @@ export default function PaymentPage() {
); );
} }
const isBusy =
isProcessing || initiateMutation.isPending || paymentMutation.isPending;
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1> <h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">
Complete payment
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6"> <p className="text-gray-600 dark:text-gray-400 mb-6">
Booking reference: <span className="font-bold text-primary">{pnr}</span> Booking reference:{" "}
<span className="font-bold text-primary">{pnr}</span>
</p> </p>
{/* Payment Processing Overlay */} {/* Processing overlay */}
{isProcessing && ( {isBusy && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md text-center"> <div className="bg-white dark:bg-gray-800 rounded-2xl p-8 max-w-sm w-full text-center shadow-2xl">
{paymentMutation.isSuccess ? ( <Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<> <h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" /> Processing payment
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment successful!</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p> <p className="text-gray-500 dark:text-gray-400 text-sm">
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" /> Please wait
</> </p>
) : (
<>
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing payment</h3>
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
</>
)}
</div> </div>
</div> </div>
)} )}
{/* Order Summary */} {/* Order summary */}
<div className="card mb-6"> <div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order summary</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Order summary
</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Route</span> <span className="text-gray-600 dark:text-gray-400">Route</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</span> <span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.origin} {selectedSchedule?.destination}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Train</span> <span className="text-gray-600 dark:text-gray-400">Train</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</span> <span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.trainNumber}
</span>
</div> </div>
{selectedSchedule?.selectedSeatClassName && ( {selectedSchedule?.selectedSeatClassName && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Class</span> <span className="text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span> Class
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Passengers</span> <span className="text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span> Passengers
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{passengers.length} passenger
{passengers.length !== 1 ? "s" : ""}
</span>
</div> </div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-gray-100">Total amount</span> <span className="text-gray-900 dark:text-gray-100">
<span className="text-primary dark:text-gray-100"> Total amount
</span>
<span className="text-primary">
ETB {(totalAmount / 100).toFixed(2)} ETB {(totalAmount / 100).toFixed(2)}
</span> </span>
</div> </div>
@@ -221,87 +252,122 @@ export default function PaymentPage() {
</div> </div>
</div> </div>
{/* Payment Methods */} {/* Payment methods */}
<div className="card mb-6"> <div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select payment method</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
<div className="space-y-3"> Select payment method
{paymentMethods.map((method) => { </h2>
const Icon = method.icon;
const isSelected = selectedMethod === method.id; {methodsLoading ? (
return ( <div className="flex items-center justify-center py-8 gap-3 text-gray-500">
<button <Loader2 className="w-5 h-5 animate-spin" />
key={method.id} <span className="text-sm">Loading payment methods</span>
onClick={() => setSelectedMethod(method.id)} </div>
disabled={isProcessing} ) : paymentMethods.length === 0 ? (
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${ <p className="text-sm text-gray-400 text-center py-6">
isSelected No payment methods available.
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-md' </p>
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary' ) : (
} ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`} <div className="space-y-3">
> {paymentMethods.map((method: any) => {
<div className="flex items-center gap-3"> const code: string =
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${ method.code || method.type || method.id || "";
isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700' const name: string =
}`}> method.name || method.displayName || code;
<Icon className={`w-6 h-6 ${isSelected ? 'text-white' : 'text-primary'}`} /> const description: string =
</div> method.description || `Pay with ${name}`;
<div className="flex-1"> const isSelected = selectedMethod === code;
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.name}</p> const Icon = METHOD_ICONS[code] || Smartphone;
<p className="text-sm text-gray-600 dark:text-gray-400">{method.description}</p> const bgColor =
</div> METHOD_COLORS[code] || "bg-gray-50 dark:bg-gray-800/50";
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center"> return (
<CheckCircle className="w-5 h-5 text-white" /> <button
key={code}
onClick={() => setSelectedMethod(code)}
disabled={isBusy}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
: `border-gray-200 dark:border-gray-700 ${bgColor} hover:border-primary`
} ${isBusy ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div
className={`w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0 ${
isSelected
? "bg-primary"
: "bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600"
}`}
>
<Icon
className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`}
/>
</div> </div>
)} <div className="flex-1 min-w-0">
</div> <p className="font-semibold text-gray-900 dark:text-gray-100">
</button> {name}
); </p>
})} <p className="text-xs text-gray-500 dark:text-gray-400 truncate">
</div> {description}
</p>
</div>
{code === "TELEBIRR" && (
<span className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 font-medium flex-shrink-0">
<ExternalLink className="w-3.5 h-3.5" />
Redirect
</span>
)}
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-5 h-5 text-white" />
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Actions */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<button <button
onClick={handlePayment} onClick={handlePayment}
disabled={!selectedMethod || isProcessing} disabled={!selectedMethod || isBusy || methodsLoading}
className={`btn-primary w-full py-4 text-lg font-semibold ${ className="btn-primary w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
!selectedMethod || isProcessing ? 'opacity-50 cursor-not-allowed' : ''
}`}
> >
{isProcessing ? ( {isBusy ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
Processing... Processing
</span> </span>
) : ( ) : (
`Pay ETB ${(totalAmount / 100).toFixed(2)}` `Pay ETB ${(totalAmount / 100).toFixed(2)}`
)} )}
</button> </button>
<button <button
onClick={() => router.back()} onClick={() => router.back()}
disabled={isProcessing} disabled={isBusy}
className="btn-secondary w-full py-2" className="btn-secondary w-full py-2"
> >
Back to review Back to review
</button> </button>
</div> </div>
{/* Error Message */} {(initiateMutation.isError || paymentMutation.isError) && (
{paymentMutation.isError && ( <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 mt-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4"> <p className="text-red-700 dark:text-red-300 text-sm font-medium">
<p className="text-red-800 dark:text-red-200 text-sm font-medium"> Payment failed. Please try again or contact support.
Payment failed. Please try again or contact support if the problem persists.
</p> </p>
</div> </div>
)} )}
{/* Security Notice */} <div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl">
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
<p className="text-xs text-gray-600 dark:text-gray-400 text-center"> 🔒 Your payment is secure and encrypted. We do not store your
🔒 Your payment is secure and encrypted. We do not store your payment information. payment information.
</p> </p>
</div> </div>
</div> </div>