Bug Fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
BIN
ordering_site/src/assets/RIT_Logo.webp
Normal file
BIN
ordering_site/src/assets/RIT_Logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
ordering_site/src/assets/Tillo.png
Normal file
BIN
ordering_site/src/assets/Tillo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
ordering_site/src/assets/display_ritz.webp
Normal file
BIN
ordering_site/src/assets/display_ritz.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
ordering_site/src/assets/front.webp
Normal file
BIN
ordering_site/src/assets/front.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
ordering_site/src/assets/tillo_spinner.gif
Normal file
BIN
ordering_site/src/assets/tillo_spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
68
ordering_site/src/components/StockAlert.tsx
Normal file
68
ordering_site/src/components/StockAlert.tsx
Normal 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;
|
||||
174
ordering_site/src/components/StockConflictModal.tsx
Normal file
174
ordering_site/src/components/StockConflictModal.tsx
Normal 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;
|
||||
48
ordering_site/src/contexts/ThemeContext.tsx
Normal file
48
ordering_site/src/contexts/ThemeContext.tsx
Normal 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;
|
||||
};
|
||||
16
ordering_site/src/firebase.ts
Normal file
16
ordering_site/src/firebase.ts
Normal 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();
|
||||
46
ordering_site/src/pages/NotFoundScreen.tsx
Normal file
46
ordering_site/src/pages/NotFoundScreen.tsx
Normal 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;
|
||||
79
ordering_site/src/pages/PreferencesScreen.tsx
Normal file
79
ordering_site/src/pages/PreferencesScreen.tsx
Normal 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;
|
||||
358
ordering_site/src/pages/TopUpScreen.css
Normal file
358
ordering_site/src/pages/TopUpScreen.css
Normal 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;
|
||||
}
|
||||
195
ordering_site/src/pages/TopUpScreen.tsx
Normal file
195
ordering_site/src/pages/TopUpScreen.tsx
Normal 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 & 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;
|
||||
322
ordering_site/src/pages/WalletScreen.css
Normal file
322
ordering_site/src/pages/WalletScreen.css
Normal 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);
|
||||
}
|
||||
221
ordering_site/src/pages/WalletScreen.tsx
Normal file
221
ordering_site/src/pages/WalletScreen.tsx
Normal 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;
|
||||
192
ordering_site/src/utils/razorpay.ts
Normal file
192
ordering_site/src/utils/razorpay.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user