Payment Gateway added
This commit is contained in:
@@ -5,7 +5,8 @@ import {
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
PlusCircle
|
||||
PlusCircle,
|
||||
CreditCard
|
||||
} from 'lucide-react';
|
||||
import tokenImage from '../assets/display_ritz.png';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -14,8 +15,11 @@ import StockConflictModal from '../components/StockConflictModal';
|
||||
import { useCart } from '../contexts/CartContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useFood } from '../contexts/FoodContext';
|
||||
import { payAndVerify, authHeaders } from '../utils/razorpay';
|
||||
import './CheckoutScreen.css';
|
||||
|
||||
type PaymentMethod = 'RITZ_TOKEN' | 'RAZORPAY';
|
||||
|
||||
const CheckoutScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { cart, totalPrice, clearCart, removeFromCart, updateQuantity } = useCart();
|
||||
@@ -23,6 +27,8 @@ const CheckoutScreen: React.FC = () => {
|
||||
const { refreshData } = useFood();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentBalance, setCurrentBalance] = useState<number>(user?.ritzTokenBalance || 0);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('RAZORPAY');
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
|
||||
// Conflict state
|
||||
const [stockConflicts, setStockConflicts] = useState<any[]>([]);
|
||||
@@ -32,14 +38,20 @@ const CheckoutScreen: React.FC = () => {
|
||||
fetchBalance();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer Ritz when balance is enough; otherwise default to online pay
|
||||
if (user && currentBalance >= totalPrice && totalPrice > 0) {
|
||||
setPaymentMethod('RITZ_TOKEN');
|
||||
} else {
|
||||
setPaymentMethod('RAZORPAY');
|
||||
}
|
||||
}, [currentBalance, totalPrice, user]);
|
||||
|
||||
const fetchBalance = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/wallet/balance/${user.id}`, {
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
headers: authHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
setCurrentBalance(data.balance || 0);
|
||||
@@ -49,66 +61,140 @@ const CheckoutScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const isInsufficient = currentBalance < totalPrice;
|
||||
const canPayWithRitz = !isInsufficient && totalPrice > 0;
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
const buildOrderItems = () =>
|
||||
cart.map(item => ({
|
||||
productId: Number(item.id),
|
||||
productName: item.isParcel ? `${item.name} (Parcel)` : item.name,
|
||||
price: item.price + (item.isParcel ? 5 : 0),
|
||||
quantity: item.quantity,
|
||||
stallId: item.stallId ? Number(item.stallId) : null,
|
||||
stallName: item.stallName || null
|
||||
}));
|
||||
|
||||
const goToSuccess = (orderNumber: string, displayOrderId: string) => {
|
||||
clearCart();
|
||||
navigate('/success', {
|
||||
state: {
|
||||
orderNumber,
|
||||
displayOrderId,
|
||||
status: 'PAID',
|
||||
paymentMethod: paymentMethod
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRitzOrder = async () => {
|
||||
if (!user) return;
|
||||
if (isInsufficient) {
|
||||
alert('Insufficient Ritz Tokens. Please top up your wallet.');
|
||||
setStatusMessage('Insufficient Ritz Tokens. Please top up or pay online.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
const orderData = {
|
||||
userId: user.id,
|
||||
totalAmount: totalPrice,
|
||||
paymentMethod: 'RITZ_TOKEN',
|
||||
orderType: 'MY_ORDER',
|
||||
items: cart.map(item => ({
|
||||
productId: Number(item.id),
|
||||
productName: item.isParcel ? `${item.name} (Parcel)` : item.name,
|
||||
price: item.price + (item.isParcel ? 5 : 0),
|
||||
quantity: item.quantity,
|
||||
stallId: item.stallId ? Number(item.stallId) : null,
|
||||
stallName: item.stallName || null
|
||||
})),
|
||||
user: { id: user.id } // Backend needs user object for token deduction
|
||||
items: buildOrderItems(),
|
||||
user: { id: user.id }
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(orderData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await refreshData();
|
||||
goToSuccess(data.orderNumber, data.displayOrderId);
|
||||
} else if (data.errorType === 'STOCK_ERROR') {
|
||||
setStockConflicts(data.conflicts || []);
|
||||
setShowConflictModal(true);
|
||||
await refreshData(true);
|
||||
} else if (data.errorType === 'TOKEN_ERROR') {
|
||||
setStatusMessage(data.message || 'Insufficient Tokens');
|
||||
fetchBalance();
|
||||
} else {
|
||||
setStatusMessage(data.message || 'Failed to place order');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRazorpayOrder = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setStatusMessage('Opening secure payment…');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify(orderData),
|
||||
const result = await payAndVerify({
|
||||
purpose: 'ORDER',
|
||||
order: {
|
||||
userId: user.id,
|
||||
totalAmount: totalPrice,
|
||||
orderType: 'MY_ORDER',
|
||||
items: buildOrderItems()
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
if (result.type === 'ORDER' && result.orderNumber) {
|
||||
await refreshData();
|
||||
clearCart();
|
||||
navigate('/success', {
|
||||
state: {
|
||||
orderNumber: data.orderNumber,
|
||||
displayOrderId: data.displayOrderId
|
||||
}
|
||||
});
|
||||
} else if (data.errorType === 'STOCK_ERROR') {
|
||||
setStockConflicts(data.conflicts || []);
|
||||
// Only show QR after backend confirms payment + order placement
|
||||
goToSuccess(result.orderNumber, result.displayOrderId || '000');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'WALLET_CREDIT_FALLBACK') {
|
||||
await refreshData();
|
||||
setStatusMessage(
|
||||
result.message ||
|
||||
'Payment received, but items became unavailable. Amount credited to your Ritz wallet.'
|
||||
);
|
||||
fetchBalance();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage(result.message || 'Payment completed but order was not confirmed. Contact support with your payment ID.');
|
||||
} catch (err: any) {
|
||||
if (err?.errorType === 'STOCK_ERROR') {
|
||||
setStockConflicts(err.conflicts || []);
|
||||
setShowConflictModal(true);
|
||||
await refreshData(true);
|
||||
} else if (data.errorType === 'TOKEN_ERROR') {
|
||||
alert(data.message || 'Insufficient Tokens');
|
||||
fetchBalance(); // Sync balance
|
||||
return;
|
||||
}
|
||||
if (err?.message === 'Payment cancelled') {
|
||||
setStatusMessage('Payment was cancelled. Your order was not placed.');
|
||||
return;
|
||||
}
|
||||
setStatusMessage(err?.message || 'Payment failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!user) return;
|
||||
if (cart.length === 0) {
|
||||
setStatusMessage('Your cart is empty.');
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'RITZ_TOKEN' && isInsufficient) {
|
||||
setStatusMessage('Insufficient Ritz Tokens. Switch to online payment or top up.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
try {
|
||||
if (paymentMethod === 'RITZ_TOKEN') {
|
||||
await handleRitzOrder();
|
||||
} else {
|
||||
alert(data.message || 'Failed to place order');
|
||||
await handleRazorpayOrder();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Order error:', error);
|
||||
alert('Error connecting to server. Please try again.');
|
||||
setStatusMessage('Error connecting to server. Please try again.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -132,6 +218,15 @@ const CheckoutScreen: React.FC = () => {
|
||||
if (remaining.length === 0) setShowConflictModal(false);
|
||||
};
|
||||
|
||||
const payLabel = () => {
|
||||
if (isProcessing) return <div className="loading-spinner-small" />;
|
||||
if (paymentMethod === 'RITZ_TOKEN') {
|
||||
if (isInsufficient) return 'Insufficient Tokens';
|
||||
return `Pay 🅡${totalPrice.toLocaleString()} & Place Order`;
|
||||
}
|
||||
return `Pay ₹${totalPrice.toLocaleString()} Online`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container checkout-page">
|
||||
<Header title="Checkout" showCart={false} />
|
||||
@@ -140,11 +235,50 @@ const CheckoutScreen: React.FC = () => {
|
||||
<section className="checkout-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Payment Method</h2>
|
||||
<p className="section-subtitle">Food orders are paid using Ritz Tokens</p>
|
||||
<p className="section-subtitle">Pay online with UPI / cards, or use Ritz Tokens</p>
|
||||
</div>
|
||||
|
||||
<div className="payment-options">
|
||||
<div className={`payment-card ritz-payment-card ${isInsufficient ? 'insufficient-state' : 'selected'}`}>
|
||||
{/* Razorpay */}
|
||||
<div
|
||||
className={`payment-card ${paymentMethod === 'RAZORPAY' ? 'selected' : ''}`}
|
||||
onClick={() => setPaymentMethod('RAZORPAY')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setPaymentMethod('RAZORPAY')}
|
||||
>
|
||||
<div className="payment-icon" style={{ background: 'rgba(0, 184, 148, 0.12)', color: '#00B894' }}>
|
||||
<CreditCard size={22} />
|
||||
</div>
|
||||
<div className="payment-info">
|
||||
<div className="payment-name-row">
|
||||
<span className="payment-name">Pay Online (Razorpay)</span>
|
||||
<span className="status-badge-premium" style={{ background: '#00B894', color: '#fff' }}>
|
||||
UPI / Card
|
||||
</span>
|
||||
</div>
|
||||
<div className="wallet-balance-info">
|
||||
<span>Secure payment · Order & QR only after success</span>
|
||||
</div>
|
||||
</div>
|
||||
{paymentMethod === 'RAZORPAY' && (
|
||||
<div className="selection-radio">
|
||||
<div className="radio-inner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ritz Tokens */}
|
||||
<div
|
||||
className={`payment-card ritz-payment-card ${
|
||||
paymentMethod === 'RITZ_TOKEN' ? 'selected' : ''
|
||||
} ${isInsufficient ? 'insufficient-state' : ''}`}
|
||||
onClick={() => canPayWithRitz && setPaymentMethod('RITZ_TOKEN')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canPayWithRitz && setPaymentMethod('RITZ_TOKEN')}
|
||||
style={{ cursor: canPayWithRitz ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="payment-icon ritz-token-avatar">
|
||||
<img src={tokenImage} alt="Ritz Token" className="token-image-main" />
|
||||
</div>
|
||||
@@ -169,7 +303,7 @@ const CheckoutScreen: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isInsufficient && (
|
||||
{paymentMethod === 'RITZ_TOKEN' && !isInsufficient && (
|
||||
<div className="selection-radio">
|
||||
<div className="radio-inner" />
|
||||
</div>
|
||||
@@ -194,19 +328,41 @@ const CheckoutScreen: React.FC = () => {
|
||||
|
||||
<div className="payment-security-note">
|
||||
<ShieldCheck size={14} />
|
||||
<span>Secured by Ritz Token Protocol. 1 Token = 🅡1.00</span>
|
||||
<span>
|
||||
{paymentMethod === 'RAZORPAY'
|
||||
? 'Secured by Razorpay. Order is placed only after payment succeeds.'
|
||||
: 'Secured by Ritz Token Protocol. 1 Token = 🅡1.00'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="order-summary-mini">
|
||||
<div className="summary-row">
|
||||
<span>Tokens to be deducted</span>
|
||||
<span className="summary-price ritz-text">🅡{totalPrice.toLocaleString()}</span>
|
||||
<span>{paymentMethod === 'RAZORPAY' ? 'Amount payable' : 'Tokens to be deducted'}</span>
|
||||
<span className="summary-price ritz-text">
|
||||
{paymentMethod === 'RAZORPAY' ? '₹' : '🅡'}{totalPrice.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="tax-info">Exclusive of any platform bonuses</p>
|
||||
<p className="tax-info">Inclusive of applicable item prices · No extra platform fee</p>
|
||||
</div>
|
||||
|
||||
{isInsufficient && (
|
||||
{statusMessage && (
|
||||
<motion.div
|
||||
className="insufficient-warning-premium"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{ margin: '0 16px 12px' }}
|
||||
>
|
||||
<div className="warning-icon-wrapper">
|
||||
<AlertCircle size={18} />
|
||||
</div>
|
||||
<div className="warning-content">
|
||||
<p className="warning-instruction" style={{ margin: 0 }}>{statusMessage}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isInsufficient && paymentMethod === 'RITZ_TOKEN' && (
|
||||
<motion.div
|
||||
className="insufficient-warning-premium"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -222,7 +378,7 @@ const CheckoutScreen: React.FC = () => {
|
||||
<div className="shortfall-amount">
|
||||
Short by <span className="highlight">🅡{(totalPrice - currentBalance).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="warning-instruction">Add tokens to your wallet to complete this order.</p>
|
||||
<p className="warning-instruction">Top up tokens, or pay online with Razorpay instead.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -232,15 +388,9 @@ const CheckoutScreen: React.FC = () => {
|
||||
<button
|
||||
className="place-order-button ritz-order-btn"
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={isProcessing || isInsufficient}
|
||||
disabled={isProcessing || cart.length === 0 || (paymentMethod === 'RITZ_TOKEN' && isInsufficient)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div className="loading-spinner-small" />
|
||||
) : isInsufficient ? (
|
||||
'Insufficient Tokens'
|
||||
) : (
|
||||
`Pay 🅡${totalPrice.toLocaleString()} & Place Order`
|
||||
)}
|
||||
{payLabel()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,12 +7,39 @@ import './SuccessScreen.css';
|
||||
const SuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const state = location.state || {};
|
||||
const orderNumber = state.orderNumber || 'ORD-TEST-UUID';
|
||||
const displayOrderId = state.displayOrderId || '000';
|
||||
const state = (location.state || {}) as {
|
||||
orderNumber?: string;
|
||||
displayOrderId?: string;
|
||||
archived?: boolean;
|
||||
status?: string;
|
||||
paymentMethod?: string;
|
||||
};
|
||||
|
||||
// QR is only shown when we have a real order from a successful placement
|
||||
const orderNumber = state.orderNumber;
|
||||
const displayOrderId = state.displayOrderId;
|
||||
const hasValidOrder = Boolean(orderNumber && displayOrderId);
|
||||
const archived = state.archived || false;
|
||||
const status = (state.status || 'PAID').toUpperCase();
|
||||
|
||||
if (!hasValidOrder) {
|
||||
return (
|
||||
<div className="container success-page">
|
||||
<div className="success-content">
|
||||
<h1 className="success-title">No order to show</h1>
|
||||
<p className="qr-hint">
|
||||
Order QR codes appear only after a successful payment and order confirmation.
|
||||
</p>
|
||||
<div className="success-actions">
|
||||
<button className="primary-button" onClick={() => navigate('/')}>
|
||||
<Home size={18} /> Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container success-page">
|
||||
<div className="success-content">
|
||||
@@ -26,7 +53,7 @@ const SuccessScreen: React.FC = () => {
|
||||
<div className={`qr-container ${status === 'COMPLETED' ? 'qr-completed' : ''} ${status === 'CANCELLED' ? 'qr-cancelled' : ''} ${archived ? 'qr-expired' : ''}`}>
|
||||
<div className="qr-wrapper-inner">
|
||||
<QRCodeCanvas
|
||||
value={orderNumber}
|
||||
value={orderNumber!}
|
||||
size={200}
|
||||
level={"H"}
|
||||
includeMargin={true}
|
||||
|
||||
Reference in New Issue
Block a user