Bug Fixes

This commit is contained in:
Sidharth Prabhu
2026-08-04 14:10:10 +05:30
parent d07ae358eb
commit f876fa9239
43 changed files with 1985 additions and 259 deletions

View File

@@ -1,5 +1,6 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { MotionConfig } from 'framer-motion';
import { CartProvider } from './contexts/CartContext';
import { AuthProvider } from './contexts/AuthContext';
import { FoodProvider } from './contexts/FoodContext';
@@ -38,81 +39,83 @@ function App() {
<FoodProvider>
<CartProvider>
<StockAlert />
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<Route path="/" element={
<ProtectedRoute>
<HomeScreen />
</ProtectedRoute>
} />
<Route path="/orders" element={
<ProtectedRoute>
<MyOrdersScreen />
</ProtectedRoute>
} />
<Route path="/category/:categoryId" element={
<ProtectedRoute>
<CategoryScreen />
</ProtectedRoute>
} />
<Route path="/item/:itemId" element={
<ProtectedRoute>
<ItemDetailScreen />
</ProtectedRoute>
} />
<Route path="/stall/:id" element={
<ProtectedRoute>
<StallDetailScreen />
</ProtectedRoute>
} />
<Route path="/cart" element={
<ProtectedRoute>
<CartScreen />
</ProtectedRoute>
} />
<Route path="/checkout" element={
<ProtectedRoute>
<CheckoutScreen />
</ProtectedRoute>
} />
<Route path="/success" element={
<ProtectedRoute>
<SuccessScreen />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfileScreen />
</ProtectedRoute>
} />
<Route path="/change-pin" element={
<ProtectedRoute>
<ChangePinScreen />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletScreen />
</ProtectedRoute>
} />
<Route path="/topup" element={
<ProtectedRoute>
<TopUpScreen />
</ProtectedRoute>
} />
<Route path="/preferences" element={
<ProtectedRoute>
<PreferencesScreen />
</ProtectedRoute>
} />
<Route path="*" element={<NotFoundScreen />} />
</Routes>
</Suspense>
</Router>
<MotionConfig skipAnimations={true}>
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<Route path="/" element={
<ProtectedRoute>
<HomeScreen />
</ProtectedRoute>
} />
<Route path="/orders" element={
<ProtectedRoute>
<MyOrdersScreen />
</ProtectedRoute>
} />
<Route path="/category/:categoryId" element={
<ProtectedRoute>
<CategoryScreen />
</ProtectedRoute>
} />
<Route path="/item/:itemId" element={
<ProtectedRoute>
<ItemDetailScreen />
</ProtectedRoute>
} />
<Route path="/stall/:id" element={
<ProtectedRoute>
<StallDetailScreen />
</ProtectedRoute>
} />
<Route path="/cart" element={
<ProtectedRoute>
<CartScreen />
</ProtectedRoute>
} />
<Route path="/checkout" element={
<ProtectedRoute>
<CheckoutScreen />
</ProtectedRoute>
} />
<Route path="/success" element={
<ProtectedRoute>
<SuccessScreen />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfileScreen />
</ProtectedRoute>
} />
<Route path="/change-pin" element={
<ProtectedRoute>
<ChangePinScreen />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletScreen />
</ProtectedRoute>
} />
<Route path="/topup" element={
<ProtectedRoute>
<TopUpScreen />
</ProtectedRoute>
} />
<Route path="/preferences" element={
<ProtectedRoute>
<PreferencesScreen />
</ProtectedRoute>
} />
<Route path="*" element={<NotFoundScreen />} />
</Routes>
</Suspense>
</Router>
</MotionConfig>
</CartProvider>
</FoodProvider>
</AuthProvider>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';
import { AlertCircle, X } from 'lucide-react';
import { useCart } from '../contexts/CartContext';
import { motion, AnimatePresence } from 'framer-motion';
const StockAlert: React.FC = () => {
const { stockError, clearStockError } = useCart();
useEffect(() => {
if (stockError) {
const timer = setTimeout(() => {
clearStockError();
}, 3000);
return () => clearTimeout(timer);
}
}, [stockError, clearStockError]);
return (
<AnimatePresence>
{stockError && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 16, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
style={{
position: 'fixed',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
width: 'calc(100% - 32px)',
maxWidth: '400px',
backgroundColor: '#FF4D4F',
color: '#ffffff',
padding: '12px 16px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(255, 77, 79, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<AlertCircle size={20} style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{stockError}</span>
</div>
<button
onClick={clearStockError}
style={{
border: 'none',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
padding: '4px',
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.15)'
}}
>
<X size={14} />
</button>
</motion.div>
)}
</AnimatePresence>
);
};
export default StockAlert;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { AlertTriangle, Trash2, Edit2, X } from 'lucide-react';
import { motion } from 'framer-motion';
interface StockConflict {
productId: number;
productName: string;
requestedQty: number;
availableQty: number;
}
interface StockConflictModalProps {
conflicts: StockConflict[];
onRemoveItem: (productId: number) => void;
onAdjustQuantity: (productId: number, newQty: number) => void;
onClose: () => void;
}
const StockConflictModal: React.FC<StockConflictModalProps> = ({
conflicts,
onRemoveItem,
onAdjustQuantity,
onClose,
}) => {
return (
<div style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
pointerEvents: 'auto'
}}>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)'
}}
/>
{/* Modal Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
style={{
position: 'relative',
width: '100%',
maxWidth: '450px',
backgroundColor: 'var(--surface)',
borderRadius: '24px',
boxShadow: 'var(--shadow)',
border: '1px solid var(--border)',
overflow: 'hidden',
padding: '24px'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#ff4d4f' }}>
<AlertTriangle size={24} />
<h3 style={{ fontSize: '1.2rem', fontWeight: 800 }}>Stock Issues Detected</h3>
</div>
<button onClick={onClose} style={{ cursor: 'pointer', border: 'none', background: 'none', color: 'var(--text-light)' }}>
<X size={20} />
</button>
</div>
<p style={{ color: 'var(--text-mid)', fontSize: '0.9rem', marginBottom: '20px' }}>
Some items in your cart do not have enough stock available to complete the order:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '24px' }}>
{conflicts.map((conflict) => (
<div
key={conflict.productId}
style={{
padding: '16px',
borderRadius: '16px',
backgroundColor: 'var(--input-bg)',
border: '1px solid var(--border)'
}}
>
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--text-dark)', marginBottom: '8px' }}>
{conflict.productName}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', color: 'var(--text-light)', marginBottom: '16px' }}>
<span>Requested: {conflict.requestedQty}</span>
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>{conflict.availableQty > 0 ? 'In Stock (Limited)' : 'Out of Stock'}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
{conflict.availableQty > 0 ? (
<button
onClick={() => onAdjustQuantity(conflict.productId, conflict.availableQty)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
backgroundColor: 'var(--primary-light)',
color: 'var(--primary)',
border: 'none',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
<Edit2 size={14} />
<span>Reduce Quantity</span>
</button>
) : (
<div style={{ color: '#ff4d4f', fontSize: '0.85rem', fontWeight: 700, display: 'flex', alignItems: 'center' }}>
Out of Stock
</div>
)}
<button
onClick={() => onRemoveItem(conflict.productId)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
backgroundColor: '#FFF1F2',
color: '#FF4D4F',
border: 'none',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
<Trash2 size={14} />
<span>Remove Item</span>
</button>
</div>
</div>
))}
</div>
<button
onClick={onClose}
style={{
width: '100%',
padding: '14px',
borderRadius: '12px',
backgroundColor: 'var(--primary)',
color: '#ffffff',
border: 'none',
fontWeight: 800,
fontSize: '0.95rem',
cursor: 'pointer'
}}
>
Return to Cart
</button>
</motion.div>
</div>
);
};
export default StockConflictModal;

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light' || savedTheme === 'dark') {
return savedTheme;
}
return 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,16 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
const firebaseConfig = {
apiKey: "AIzaSyDOEiKtH-gs2nAx8Di45wJf8CY5nPm4xPE",
authDomain: "tillo-c8de9.firebaseapp.com",
projectId: "tillo-c8de9",
storageBucket: "tillo-c8de9.firebasestorage.app",
messagingSenderId: "200333262782",
appId: "1:200333262782:web:0d988352ee6a44d7a943ef",
measurementId: "G-YB01EQT2VW"
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Home } from 'lucide-react';
const NotFoundScreen: React.FC = () => {
const navigate = useNavigate();
return (
<div className="container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px', textAlign: 'center' }}>
<div style={{ width: '80px', height: '80px', borderRadius: '50%', backgroundColor: 'var(--primary-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--primary)', marginBottom: '24px' }}>
<AlertTriangle size={40} />
</div>
<h1 style={{ fontSize: '2rem', fontWeight: 900, color: 'var(--text-dark)', marginBottom: '12px' }}>
Page Not Found
</h1>
<p style={{ color: 'var(--text-light)', fontSize: '0.95rem', maxWidth: '320px', marginBottom: '32px' }}>
The page you are looking for doesn't exist or has been moved to another URL.
</p>
<button
onClick={() => navigate('/')}
style={{
backgroundColor: 'var(--primary)',
color: '#ffffff',
padding: '12px 24px',
borderRadius: '12px',
fontWeight: 700,
fontSize: '0.95rem',
display: 'flex',
alignItems: 'center',
gap: '8px',
border: 'none',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(35, 22, 81, 0.15)'
}}
>
<Home size={18} />
<span>Go to Home</span>
</button>
</div>
);
};
export default NotFoundScreen;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import { useTheme } from '../contexts/ThemeContext';
import { Moon, Sun, Globe, ChevronRight } from 'lucide-react';
const PreferencesScreen: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return (
<div className="container preferences-page">
<Header title="Preferences" showCart={false} />
<main className="safe-area-bottom">
<div style={{ padding: '20px' }}>
<div
className="preference-item"
onClick={toggleTheme}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
marginBottom: '16px',
cursor: 'pointer',
boxShadow: 'var(--shadow)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{theme === 'dark' ? <Moon size={20} color="var(--primary)" /> : <Sun size={20} color="var(--primary)" />}
<div>
<div style={{ fontWeight: 600, color: 'var(--text-dark)' }}>Dark Mode</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-light)' }}>
{theme === 'dark' ? 'Enabled' : 'Disabled'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--text-light)', marginRight: '8px' }}>
{theme === 'dark' ? 'On' : 'Off'}
</span>
<ChevronRight size={18} color="var(--text-light)" />
</div>
</div>
<div
className="preference-item"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
marginBottom: '16px',
boxShadow: 'var(--shadow)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Globe size={20} color="var(--primary)" />
<div>
<div style={{ fontWeight: 600, color: 'var(--text-dark)' }}>Language</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-light)' }}>Change application language</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--text-light)', marginRight: '8px' }}>English</span>
<ChevronRight size={18} color="var(--text-light)" />
</div>
</div>
</div>
</main>
<BottomNav />
</div>
);
};
export default PreferencesScreen;

View File

@@ -0,0 +1,358 @@
/* ═══════════════════════════════════════════════
TopUp Screen — Premium Razorpay Recharge UI
═══════════════════════════════════════════════ */
.topup-page main {
padding-bottom: 40px;
}
/* ── Back Button ─────────────────────────────── */
.topup-back-btn {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-mid);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 20px;
border: none;
background: none;
cursor: pointer;
padding: 4px 0;
transition: color 0.2s;
}
.topup-back-btn:hover {
color: var(--text-dark);
}
/* ── Balance Card ────────────────────────────── */
.topup-balance-card {
background: linear-gradient(135deg, #001828 0%, #4a3e7a 100%);
color: #ffffff;
padding: 24px;
border-radius: 20px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 8px 32px rgba(35, 22, 81, 0.18);
}
.topup-balance-card::before {
content: '';
position: absolute;
top: -30px;
right: -30px;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
}
.topup-balance-card::after {
content: '';
position: absolute;
bottom: -20px;
left: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
}
.balance-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.8;
margin-bottom: 8px;
}
.balance-amount {
font-size: 2rem;
font-weight: 900;
letter-spacing: -0.5px;
}
/* ── Razorpay Info Banner ────────────────────── */
.razorpay-info-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(var(--primary-rgb), 0.08);
border: 1px solid rgba(var(--primary-rgb), 0.2);
margin-bottom: 20px;
}
.razorpay-info-banner .banner-icon {
color: var(--primary);
flex-shrink: 0;
}
.razorpay-info-banner .banner-text {
font-size: 0.85rem;
color: var(--text-mid);
line-height: 1.4;
}
.razorpay-info-banner .banner-text strong {
color: var(--text-dark);
display: block;
margin-bottom: 2px;
}
/* ── Form Styles ─────────────────────────────── */
.topup-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.topup-form label {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-mid);
margin-bottom: 8px;
display: block;
}
.amount-input-wrapper {
position: relative;
}
.amount-input-wrapper .currency-symbol {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 1.5rem;
font-weight: 800;
color: var(--primary);
pointer-events: none;
}
.amount-input-wrapper input {
width: 100%;
padding: 16px 16px 16px 40px;
font-size: 1.5rem;
font-weight: 800;
border-radius: 16px;
border: 2px solid var(--border);
background-color: var(--surface);
color: var(--text-dark);
outline: none;
transition: border-color 0.25s, box-shadow 0.25s;
font-family: inherit;
}
.amount-input-wrapper input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.1);
}
.amount-input-wrapper input::placeholder {
color: var(--text-light);
font-weight: 600;
}
/* ── Quick Presets ────────────────────────────── */
.presets-label {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-light);
margin-bottom: 10px;
display: block;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.preset-btn {
padding: 12px 6px;
border-radius: 12px;
border: 1.5px solid var(--border);
background-color: var(--surface);
color: var(--text-dark);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
font-family: inherit;
}
.preset-btn:hover {
border-color: var(--primary);
transform: translateY(-1px);
}
.preset-btn.active {
background-color: var(--primary);
color: #ffffff;
border-color: var(--primary);
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25);
}
/* ── Error Message ───────────────────────────── */
.topup-error {
color: #FF4D4F;
background-color: #FFF1F2;
padding: 12px 16px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid #fecaca;
display: flex;
align-items: center;
gap: 8px;
animation: topup-error-shake 0.35s ease;
}
@keyframes topup-error-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
/* ── Pay Button ──────────────────────────────── */
.topup-submit-btn {
background-color: var(--primary);
color: #ffffff;
padding: 16px;
border-radius: 16px;
font-weight: 800;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
margin-top: 10px;
font-family: inherit;
position: relative;
overflow: hidden;
}
.topup-submit-btn:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(var(--primary-rgb), 0.3);
}
.topup-submit-btn:not(:disabled):active {
transform: scale(0.98);
}
.topup-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.topup-submit-btn.processing {
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 70%, #000));
}
/* ── Shimmer animation for processing state ── */
.topup-submit-btn.processing::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
/* ── Security Footer ─────────────────────────── */
.topup-security-footer {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-light);
font-size: 0.8rem;
justify-content: center;
margin-top: 24px;
}
/* ── Success Overlay ─────────────────────────── */
.topup-success-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
text-align: center;
}
.topup-success-icon {
animation: topup-success-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
margin-bottom: 24px;
}
@keyframes topup-success-pop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.topup-success-title {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-dark);
margin-bottom: 12px;
}
.topup-success-amount {
color: var(--text-light);
font-size: 0.95rem;
margin-bottom: 24px;
}
.topup-success-redirect {
color: var(--primary);
font-weight: 600;
font-size: 0.85rem;
animation: topup-pulse 1.5s ease-in-out infinite;
}
@keyframes topup-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Conversion hint ─────────────────────────── */
.conversion-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-light);
margin-top: 4px;
padding-left: 2px;
}
.conversion-hint .equals-sign {
color: var(--primary);
font-weight: 700;
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Wallet, ShieldCheck, CheckCircle, CreditCard, Zap } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import Header from '../components/Header';
import { payAndVerify } from '../utils/razorpay';
import './TopUpScreen.css';
const TopUpScreen: React.FC = () => {
const navigate = useNavigate();
const { user, refreshUser } = useAuth();
const [amount, setAmount] = useState<string>('');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [creditedAmount, setCreditedAmount] = useState<number>(0);
const presets = [50, 100, 200, 500];
const handleTopUp = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount < 50) {
setError('Minimum top up amount is ₹50.');
return;
}
if (parsedAmount > 5000) {
setError('Maximum top up limit per transaction is ₹5,000.');
return;
}
if (!Number.isInteger(parsedAmount)) {
setError('Please enter a whole number amount (no paise).');
return;
}
setIsProcessing(true);
setError(null);
try {
const result = await payAndVerify({
purpose: 'WALLET_TOPUP',
amount: parsedAmount
});
if (result.success && (result.type === 'WALLET_TOPUP' || result.purpose === 'WALLET_TOPUP')) {
setCreditedAmount(parsedAmount);
setSuccess(true);
await refreshUser();
setTimeout(() => {
navigate('/wallet');
}, 2200);
} else {
setError(result.message || 'Payment succeeded but wallet was not credited. Contact support with your payment ID.');
}
} catch (err: any) {
console.error('Top up error:', err);
if (err?.message === 'Payment cancelled') {
setError('Payment was cancelled. No tokens were added.');
} else {
setError(err?.message || 'Connection error. Please try again.');
}
} finally {
setIsProcessing(false);
}
};
if (success) {
return (
<div className="container topup-page">
<div className="topup-success-overlay">
<div className="topup-success-icon">
<CheckCircle size={64} color="var(--primary)" />
</div>
<h2 className="topup-success-title">
Top Up Successful!
</h2>
<p className="topup-success-amount">
🅡{creditedAmount.toLocaleString()} Ritz tokens have been added to your wallet.
</p>
<div className="topup-success-redirect">
Redirecting to your wallet...
</div>
</div>
</div>
);
}
return (
<div className="container topup-page">
<Header title="Top Up Tokens" showCart={false} />
<main style={{ padding: '20px' }}>
<button
onClick={() => navigate(-1)}
className="topup-back-btn"
>
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Balance Card */}
<div className="topup-balance-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div className="balance-label">Current Balance</div>
<Wallet size={20} style={{ opacity: 0.7 }} />
</div>
<div className="balance-amount">🅡{(user?.ritzTokenBalance || 0).toLocaleString()}</div>
</div>
{/* Razorpay Info */}
<div className="razorpay-info-banner">
<CreditCard size={20} className="banner-icon" />
<div className="banner-text">
<strong>Pay securely with Razorpay</strong>
UPI, cards &amp; netbanking. Tokens are credited only after payment succeeds.
</div>
</div>
<form onSubmit={handleTopUp} className="topup-form">
<div>
<label>Enter Top Up Amount ()</label>
<div className="amount-input-wrapper">
<span className="currency-symbol"></span>
<input
type="number"
value={amount}
onChange={(e) => {
setAmount(e.target.value);
setError(null);
}}
placeholder="0"
min="50"
max="5000"
step="1"
required
/>
</div>
{amount && Number(amount) > 0 && (
<div className="conversion-hint">
<Zap size={12} />
<span>{Number(amount).toLocaleString()}</span>
<span className="equals-sign">=</span>
<span>🅡{Number(amount).toLocaleString()} Ritz Tokens</span>
</div>
)}
</div>
<div>
<span className="presets-label">Quick Add Presets</span>
<div className="presets-grid">
{presets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
setAmount(preset.toString());
setError(null);
}}
className={`preset-btn ${amount === preset.toString() ? 'active' : ''}`}
>
+{preset}
</button>
))}
</div>
</div>
{error && (
<div className="topup-error">
{error}
</div>
)}
<button
type="submit"
disabled={isProcessing || !amount}
className={`topup-submit-btn ${isProcessing ? 'processing' : ''}`}
>
{isProcessing ? 'Processing Payment...' : `Pay ₹${amount || '0'} & Top Up`}
</button>
</form>
<div className="topup-security-footer">
<ShieldCheck size={14} />
<span>Secured by Razorpay · 1 Ritz Token = 1.00</span>
</div>
</main>
</div>
);
};
export default TopUpScreen;

View File

@@ -0,0 +1,322 @@
/* ═══════════════════════════════════════════════
Wallet Screen — Premium Ritz Wallet UI
═══════════════════════════════════════════════ */
.wallet-page main {
padding: 20px;
padding-bottom: 100px;
}
/* ── Gradient Balance Card ───────────────────── */
.wallet-balance-card {
background: linear-gradient(135deg, #001828 0%, #4a3e7a 100%);
color: #ffffff;
padding: 24px;
borderRadius: 24px;
box-shadow: 0 8px 32px rgba(35, 22, 81, 0.15);
margin-bottom: 24px;
position: relative;
overflow: hidden;
border-radius: 24px;
}
.wallet-balance-card::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 130px;
height: 130px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
pointer-events: none;
}
.wallet-balance-card::after {
content: '';
position: absolute;
bottom: -30px;
left: 30px;
width: 90px;
height: 90px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.03);
pointer-events: none;
}
.wallet-balance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.wallet-balance-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.8;
}
.wallet-balance-icon {
opacity: 0.8;
}
.wallet-balance-display {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.wallet-token-image {
width: 40px;
height: 40px;
object-fit: contain;
}
.wallet-balance-value {
font-size: 2.5rem;
font-weight: 900;
letter-spacing: -1px;
}
/* ── Top Up Button (inside balance card) ─────── */
.wallet-topup-btn {
width: 100%;
background-color: #ffffff;
color: #001828;
padding: 12px;
border-radius: 12px;
font-weight: 700;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.wallet-topup-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.wallet-topup-btn:active {
transform: scale(0.98);
}
/* ── Coupon Card ─────────────────────────────── */
.wallet-coupon-card {
background-color: var(--surface);
padding: 20px;
border-radius: 24px;
box-shadow: var(--shadow);
margin-bottom: 24px;
border: 1px solid var(--border);
}
.wallet-coupon-card h4 {
font-size: 0.95rem;
font-weight: 800;
color: var(--text-dark);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wallet-coupon-card p {
font-size: 0.8rem;
color: var(--text-light);
margin-bottom: 16px;
}
.coupon-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-input-row {
display: flex;
gap: 8px;
}
.coupon-input {
flex: 1;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background-color: var(--input-bg);
color: var(--text-dark);
font-weight: 600;
font-size: 0.9rem;
outline: none;
text-transform: uppercase;
font-family: inherit;
transition: border-color 0.2s;
}
.coupon-input:focus {
border-color: var(--primary);
}
.coupon-apply-btn {
background-color: var(--primary);
color: #ffffff;
padding: 0 20px;
border-radius: 12px;
font-weight: 700;
font-size: 0.9rem;
border: none;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
font-family: inherit;
}
.coupon-apply-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.coupon-apply-btn:not(:disabled):active {
transform: scale(0.96);
}
.coupon-status {
padding: 10px 14px;
border-radius: 10px;
font-size: 0.8rem;
font-weight: 600;
animation: coupon-status-slide 0.3s ease;
}
.coupon-status.success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.coupon-status.error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
@keyframes coupon-status-slide {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Transactions Section ────────────────────── */
.wallet-transactions-header {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 16px;
}
.wallet-transactions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.wallet-tx-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: var(--surface);
border-radius: 16px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: transform 0.15s;
}
.wallet-tx-card:hover {
transform: translateY(-1px);
}
.wallet-tx-left {
display: flex;
align-items: center;
gap: 12px;
}
.wallet-tx-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.wallet-tx-icon.credit {
background-color: #E6FFFA;
color: #00B894;
}
.wallet-tx-icon.debit {
background-color: #FFF1F2;
color: #FF4D4F;
}
.wallet-tx-description {
font-weight: 700;
color: var(--text-dark);
font-size: 0.95rem;
}
.wallet-tx-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-light);
margin-top: 2px;
}
.wallet-tx-ref {
opacity: 0.7;
}
.wallet-tx-amount {
font-weight: 800;
font-size: 1.05rem;
white-space: nowrap;
}
.wallet-tx-amount.credit {
color: #00B894;
}
.wallet-tx-amount.debit {
color: #FF4D4F;
}
.wallet-empty-state {
text-align: center;
padding: 40px 20px;
background-color: var(--surface);
border-radius: 16px;
color: var(--text-light);
box-shadow: var(--shadow);
}
.wallet-loading-state {
text-align: center;
padding: 40px;
color: var(--text-light);
}

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wallet, PlusCircle, ArrowUpRight, ArrowDownLeft, Calendar } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { authHeaders } from '../utils/razorpay';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import tokenImage from '../assets/display_ritz.webp';
import './WalletScreen.css';
interface Transaction {
id: number;
amount: number;
type: 'TOPUP' | 'SPEND' | 'REFUND';
description: string;
timestamp: string;
referenceId: string;
}
const WalletScreen: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [balance, setBalance] = useState<number>(user?.ritzTokenBalance || 0);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Coupon States
const [couponCode, setCouponCode] = useState('');
const [redeemStatus, setRedeemStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const [isRedeeming, setIsRedeeming] = useState(false);
useEffect(() => {
if (user) {
fetchWalletData();
}
}, [user]);
const fetchWalletData = async () => {
if (!user) return;
try {
setIsLoading(true);
// Fetch balance
const balanceRes = await fetch(`http://${window.location.hostname}:8080/api/wallet/balance/${user.id}`, {
headers: authHeaders()
});
if (balanceRes.ok) {
const balanceData = await balanceRes.json();
setBalance(balanceData.balance || 0);
}
// Fetch transactions
const txRes = await fetch(`http://${window.location.hostname}:8080/api/wallet/transactions/${user.id}`, {
headers: authHeaders()
});
if (txRes.ok) {
const txData = await txRes.json();
setTransactions(txData || []);
}
} catch (error) {
console.error('Error fetching wallet data:', error);
} finally {
setIsLoading(false);
}
};
const handleRedeemCoupon = async (e: React.FormEvent) => {
e.preventDefault();
if (!couponCode.trim()) return;
try {
setIsRedeeming(true);
setRedeemStatus({ type: null, message: '' });
const response = await fetch(`http://${window.location.hostname}:8080/api/coupons/redeem`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
code: couponCode.trim(),
userId: user?.id
})
});
const data = await response.json();
if (response.ok && data.success) {
setRedeemStatus({
type: 'success',
message: data.message || `Successfully redeemed coupon code!`
});
setCouponCode('');
// Refresh balance and transactions
fetchWalletData();
} else {
setRedeemStatus({
type: 'error',
message: data.message || 'Failed to redeem coupon.'
});
}
} catch (error) {
console.error('Error redeeming coupon:', error);
setRedeemStatus({
type: 'error',
message: 'Something went wrong. Please try again.'
});
} finally {
setIsRedeeming(false);
}
};
return (
<div className="container wallet-page">
<Header title="My Wallet" showCart={false} />
<main className="safe-area-bottom" style={{ padding: '20px' }}>
{/* Balance Card */}
<div className="wallet-balance-card">
<div className="wallet-balance-header">
<span className="wallet-balance-label">Ritz Token Balance</span>
<Wallet size={20} className="wallet-balance-icon" />
</div>
<div className="wallet-balance-display">
<img src={tokenImage} alt="Ritz Token" className="wallet-token-image" />
<span className="wallet-balance-value">
🅡{balance.toLocaleString()}
</span>
</div>
<button
onClick={() => navigate('/topup')}
className="wallet-topup-btn"
>
<PlusCircle size={18} />
<span>Top Up Tokens via Razorpay</span>
</button>
</div>
{/* Redeem Coupon Card */}
<div className="wallet-coupon-card">
<h4>Redeem Coupon Code</h4>
<p>Claim promo codes or reward vouchers to instantly credit tokens to your wallet.</p>
<form onSubmit={handleRedeemCoupon} className="coupon-form">
<div className="coupon-input-row">
<input
type="text"
placeholder="Enter Coupon Code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
disabled={isRedeeming}
className="coupon-input"
/>
<button
type="submit"
disabled={isRedeeming || !couponCode.trim()}
className="coupon-apply-btn"
>
{isRedeeming ? 'Redeeming...' : 'Apply'}
</button>
</div>
{redeemStatus.type && (
<div className={`coupon-status ${redeemStatus.type}`}>
{redeemStatus.message}
</div>
)}
</form>
</div>
{/* Transactions Section */}
<div>
<h3 className="wallet-transactions-header">
Transaction History
</h3>
{isLoading ? (
<div className="wallet-loading-state">
Loading transactions...
</div>
) : transactions.length === 0 ? (
<div className="wallet-empty-state">
No transactions yet. Top up your wallet to get started!
</div>
) : (
<div className="wallet-transactions-list">
{transactions.map((tx) => {
const isCredit = tx.type === 'TOPUP' || tx.type === 'REFUND';
return (
<div key={tx.id} className="wallet-tx-card">
<div className="wallet-tx-left">
<div className={`wallet-tx-icon ${isCredit ? 'credit' : 'debit'}`}>
{isCredit ? <ArrowDownLeft size={20} /> : <ArrowUpRight size={20} />}
</div>
<div>
<div className="wallet-tx-description">
{tx.description || (tx.type === 'TOPUP' ? 'Wallet Top Up' : tx.type === 'REFUND' ? 'Refund' : 'Order Payment')}
</div>
<div className="wallet-tx-meta">
<Calendar size={12} />
<span>{new Date(tx.timestamp).toLocaleString()}</span>
{tx.referenceId && <span className="wallet-tx-ref"> {tx.referenceId}</span>}
</div>
</div>
</div>
<div className={`wallet-tx-amount ${isCredit ? 'credit' : 'debit'}`}>
{isCredit ? '+' : '-'}🅡{tx.amount.toLocaleString()}
</div>
</div>
);
})}
</div>
)}
</div>
</main>
<BottomNav />
</div>
);
};
export default WalletScreen;

View File

@@ -0,0 +1,192 @@
/**
* Razorpay Checkout helpers for the ordering site.
* Secret key never touches the browser — only key_id + order_id from the backend.
*/
export type RazorpayPrefill = {
name?: string;
email?: string;
contact?: string;
};
export type CreatePaymentResponse = {
success: boolean;
paymentSessionId: number;
keyId: string;
razorpayOrderId: string;
amount: number; // paise
amountInr: number;
currency: string;
purpose: string;
name: string;
description: string;
prefill?: RazorpayPrefill;
notes?: Record<string, string>;
message?: string;
errorType?: string;
conflicts?: unknown[];
};
export type VerifyPaymentResponse = {
success: boolean;
type?: string;
purpose?: string;
status?: string;
orderNumber?: string;
displayOrderId?: string;
newBalance?: number;
message?: string;
creditedAmount?: number;
razorpayPaymentId?: string;
};
export type RazorpaySuccessResponse = {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
};
declare global {
interface Window {
Razorpay?: new (options: Record<string, unknown>) => {
open: () => void;
on: (event: string, handler: (response: unknown) => void) => void;
};
}
}
const API_HOST = () => `http://${window.location.hostname}:8080`;
export function authHeaders(): HeadersInit {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
let razorpayScriptPromise: Promise<void> | null = null;
export function loadRazorpayScript(): Promise<void> {
if (window.Razorpay) return Promise.resolve();
if (razorpayScriptPromise) return razorpayScriptPromise;
razorpayScriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-razorpay="true"]');
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('Failed to load Razorpay SDK')));
return;
}
const script = document.createElement('script');
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
script.async = true;
script.dataset.razorpay = 'true';
script.onload = () => resolve();
script.onerror = () => {
razorpayScriptPromise = null;
reject(new Error('Failed to load Razorpay Checkout. Check your network connection.'));
};
document.body.appendChild(script);
});
return razorpayScriptPromise;
}
export async function createPayment(body: Record<string, unknown>): Promise<CreatePaymentResponse> {
const response = await fetch(`${API_HOST()}/api/payments/create`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok && data.success !== false) {
throw new Error(data.message || 'Failed to create payment');
}
return data;
}
export async function verifyPayment(payload: {
razorpayOrderId: string;
razorpayPaymentId: string;
razorpaySignature: string;
}): Promise<VerifyPaymentResponse> {
const response = await fetch(`${API_HOST()}/api/payments/verify`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Payment verification failed');
}
return data;
}
/**
* Opens Razorpay Checkout and resolves only after the user completes payment.
* Does NOT place the order — caller must call verifyPayment next.
*/
export async function openRazorpayCheckout(
checkout: CreatePaymentResponse
): Promise<RazorpaySuccessResponse> {
await loadRazorpayScript();
const RazorpayCtor = window.Razorpay;
if (!RazorpayCtor) {
throw new Error('Razorpay SDK is not available');
}
return new Promise((resolve, reject) => {
const options: Record<string, unknown> = {
key: checkout.keyId,
amount: checkout.amount,
currency: checkout.currency || 'INR',
name: checkout.name || 'Tillo Canteen',
description: checkout.description || 'Payment',
order_id: checkout.razorpayOrderId,
prefill: checkout.prefill || {},
notes: checkout.notes || {},
theme: { color: '#00B894' },
modal: {
ondismiss: () => {
reject(new Error('Payment cancelled'));
},
},
handler: (response: RazorpaySuccessResponse) => {
resolve(response);
},
};
const rzp = new RazorpayCtor(options);
rzp.on('payment.failed', (resp: unknown) => {
const err = resp as { error?: { description?: string; reason?: string } };
reject(new Error(err?.error?.description || err?.error?.reason || 'Payment failed'));
});
rzp.open();
});
}
/**
* Full pay → verify flow used by checkout and wallet top-up.
*/
export async function payAndVerify(
createBody: Record<string, unknown>
): Promise<VerifyPaymentResponse> {
const created = await createPayment(createBody);
if (!created.success) {
const err = new Error(created.message || 'Could not start payment') as Error & {
errorType?: string;
conflicts?: unknown[];
};
err.errorType = created.errorType;
err.conflicts = created.conflicts;
throw err;
}
const payment = await openRazorpayCheckout(created);
return verifyPayment({
razorpayOrderId: payment.razorpay_order_id,
razorpayPaymentId: payment.razorpay_payment_id,
razorpaySignature: payment.razorpay_signature,
});
}