Payment Gateway added

This commit is contained in:
Sidharth Prabhu
2026-07-24 12:06:56 +05:30
parent f8bd97229b
commit ba06c1a7a6
16609 changed files with 2707174 additions and 155 deletions

View File

@@ -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 &amp; 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>

View File

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