Repeat orders added

This commit is contained in:
Sidharth Prabhu
2026-04-17 11:19:18 +05:30
parent 2d24b4d71b
commit acfe9598cb
8 changed files with 686 additions and 83 deletions

View File

@@ -8,6 +8,7 @@
"name": "ordering_site",
"version": "0.0.0",
"dependencies": {
"framer-motion": "^12.38.0",
"lucide-react": "^1.7.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
@@ -1834,6 +1835,33 @@
"dev": true,
"license": "ISC"
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2398,6 +2426,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2824,9 +2867,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.38.0",
"lucide-react": "^1.7.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",

View File

@@ -9,6 +9,7 @@ interface AuthContextType {
register: (mobileNumber: string, name: string, pin: string) => Promise<{ success: boolean; message: string }>;
logout: () => void;
changePin: (currentPin: string, newPin: string) => Promise<{ success: boolean; message: string }>;
updateProfile: (name: string, mobileNumber: string) => Promise<{ success: boolean; message: string }>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -29,7 +30,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const checkUserExists = async (mobileNumber: string) => {
try {
const response = await fetch(`${API_BASE_URL}/check`, {
const response = await fetch(`${API_BASE_URL}/check`, { cache: 'no-store',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobileNumber }),
@@ -42,7 +43,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = async (mobileNumber: string, pin: string) => {
try {
const response = await fetch(`${API_BASE_URL}/login`, {
const response = await fetch(`${API_BASE_URL}/login`, { cache: 'no-store',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobileNumber, pin }),
@@ -61,7 +62,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const register = async (mobileNumber: string, name: string, pin: string) => {
try {
const response = await fetch(`${API_BASE_URL}/register`, {
const response = await fetch(`${API_BASE_URL}/register`, { cache: 'no-store',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobileNumber, name, pin }),
@@ -80,7 +81,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const logout = () => {
if (user) {
fetch(`${API_BASE_URL}/logout`, {
fetch(`${API_BASE_URL}/logout`, { cache: 'no-store',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobileNumber: user.mobileNumber }),
@@ -93,7 +94,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const changePin = async (currentPin: string, newPin: string) => {
if (!user) return { success: false, message: 'Not logged in' };
try {
const response = await fetch(`${API_BASE_URL}/change-pin`, {
const response = await fetch(`${API_BASE_URL}/change-pin`, { cache: 'no-store',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobileNumber: user.mobileNumber, currentPin, newPin }),
@@ -105,8 +106,38 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
const updateProfile = async (name: string, mobileNumber: string) => {
if (!user) return { success: false, message: 'Not logged in' };
try {
const response = await fetch(`${API_BASE_URL}/users/${user.id}`, { cache: 'no-store',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, mobileNumber }),
});
if (response.ok) {
const updatedUserDto = await response.json();
// The DTO from backend might have different structure, let's map it back to User type
const updatedUser: User = {
id: updatedUserDto.id,
name: updatedUserDto.name,
mobileNumber: updatedUserDto.mobileNumber,
isLoggedIn: updatedUserDto.loggedIn
};
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
return { success: true, message: 'Profile updated successfully' };
} else {
const errorData = await response.json();
return { success: false, message: errorData.message || 'Failed to update profile' };
}
} catch (error) {
return { success: false, message: 'Network error. Please try again.' };
}
};
return (
<AuthContext.Provider value={{ user, isLoading, checkUserExists, login, register, logout, changePin }}>
<AuthContext.Provider value={{ user, isLoading, checkUserExists, login, register, logout, changePin, updateProfile }}>
{children}
</AuthContext.Provider>
);

View File

@@ -29,9 +29,9 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
setError(null);
try {
const [baseItemsRes, productsRes, stallsRes] = await Promise.all([
fetch(`${API_BASE_URL}/base-items?size=100`),
fetch(`${API_BASE_URL}/products?size=100`),
fetch(`${API_BASE_URL}/stalls/active`)
fetch(`${API_BASE_URL}/base-items?size=100`, { cache: 'no-store' }),
fetch(`${API_BASE_URL}/products?size=100`, { cache: 'no-store' }),
fetch(`${API_BASE_URL}/stalls/active`, { cache: 'no-store' })
]);
if (!baseItemsRes.ok || !productsRes.ok || !stallsRes.ok) {

View File

@@ -319,3 +319,148 @@
margin-bottom: 20px;
opacity: 0.5;
}
/* Repeat Order Button */
.repeat-order-btn {
width: 100%;
padding: 16px;
background-color: var(--primary);
color: white;
border-radius: 16px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 24px;
border: none;
cursor: pointer;
box-shadow: 0 4px 15px rgba(35, 22, 81, 0.2);
transition: transform 0.2s;
}
.repeat-order-btn:active {
transform: scale(0.98);
}
.repeat-order-btn.mini {
margin-top: 12px;
padding: 12px;
font-size: 13px;
background-color: var(--primary-light);
color: var(--primary);
box-shadow: none;
}
/* Stock Alert Modal */
.stock-alert-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.stock-alert-modal {
background: white;
width: 100%;
max-width: 400px;
border-radius: 28px;
padding: 32px 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes modalPop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.alert-icon-wrapper {
width: 64px;
height: 64px;
background: #fff1f2;
color: #e11d48;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.stock-alert-modal h3 {
font-size: 20px;
font-weight: 800;
color: var(--text-dark);
margin-bottom: 12px;
}
.stock-alert-modal p {
font-size: 14px;
color: var(--text-mid);
margin-bottom: 20px;
line-height: 1.5;
}
.unavailable-items-list {
width: 100%;
background: var(--bg);
border-radius: 16px;
padding: 16px;
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.unavailable-item-chip {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #e11d48;
background: white;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid #fecdd3;
}
.alert-actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.btn-proceed {
width: 100%;
padding: 16px;
background: var(--primary);
color: white;
border-radius: 16px;
font-weight: 700;
border: none;
}
.btn-discard {
width: 100%;
padding: 16px;
background: var(--bg);
color: var(--text-mid);
border-radius: 16px;
font-weight: 700;
border: 1px solid var(--border);
}

View File

@@ -1,9 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Clock, ShoppingBag as ShoppingBagIcon, ChevronRight as ChevronRightIcon, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Clock, ShoppingBag as ShoppingBagIcon, ChevronRight as ChevronRightIcon, X, RefreshCcw, AlertCircle, ShoppingCart } from 'lucide-react';
import { QRCodeCanvas } from 'qrcode.react';
import { motion, AnimatePresence } from 'framer-motion';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import { useAuth } from '../contexts/AuthContext';
import { useFood } from '../contexts/FoodContext';
import { useCart } from '../contexts/CartContext';
import type { FoodItem } from '../types';
import './MyOrdersScreen.css';
interface Order {
@@ -23,11 +28,20 @@ interface Order {
}
const MyOrdersScreen: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { foodItems } = useFood();
const { addToCart, clearCart } = useCart();
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
// State for stock alert
const [showStockAlert, setShowStockAlert] = useState(false);
const [unavailableItems, setUnavailableItems] = useState<string[]>([]);
const [pendingItems, setPendingItems] = useState<FoodItem[]>([]);
useEffect(() => {
if (user?.id) {
fetchOrders();
@@ -46,6 +60,50 @@ const MyOrdersScreen: React.FC = () => {
}
};
const handleRepeatOrder = (order: Order) => {
const missing: string[] = [];
const available: FoodItem[] = [];
order.items.forEach(orderItem => {
// Find the current food item by name (since orders store names)
// Robustness: matching by name is fallback if IDs aren't directly available in OrderItem DTO
const currentItem = foodItems.find(fi => fi.name === orderItem.productName);
if (currentItem) {
if (currentItem.stock && currentItem.stock > 0) {
// Add multiple times based on original quantity
for (let i = 0; i < orderItem.quantity; i++) {
available.push(currentItem);
}
} else {
missing.push(orderItem.productName);
}
} else {
missing.push(orderItem.productName); // Item no longer exists in catalog
}
});
if (missing.length > 0) {
setUnavailableItems(missing);
setPendingItems(available);
setShowStockAlert(true);
} else {
// All items available
performRepeat(available);
}
};
const performRepeat = (items: FoodItem[]) => {
clearCart();
items.forEach(item => addToCart(item));
navigate('/cart');
};
const handleProceedPartial = () => {
performRepeat(pendingItems);
setShowStockAlert(false);
};
const latestOrder = orders.length > 0 ? orders[0] : null;
const previousOrders = orders.length > 1 ? orders.slice(1) : [];
@@ -108,6 +166,16 @@ const MyOrdersScreen: React.FC = () => {
<span>Total Amount</span>
<span className="amount-text">{latestOrder.totalAmount.toFixed(2)}</span>
</div>
<button
className="repeat-order-btn mini"
onClick={(e) => {
e.stopPropagation();
handleRepeatOrder(latestOrder);
}}
>
<RefreshCcw size={16} /> Repeat Order
</button>
</div>
</section>
)}
@@ -143,9 +211,23 @@ const MyOrdersScreen: React.FC = () => {
</main>
{/* Order Detail Modal / Overlay */}
<AnimatePresence>
{selectedOrder && (
<div className="order-detail-overlay" onClick={() => setSelectedOrder(null)}>
<div className="order-detail-modal" onClick={(e) => e.stopPropagation()}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="order-detail-overlay"
onClick={() => setSelectedOrder(null)}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="order-detail-modal"
onClick={(e) => e.stopPropagation()}
>
<button className="close-details" onClick={() => setSelectedOrder(null)}>
<X size={24} />
</button>
@@ -208,9 +290,62 @@ const MyOrdersScreen: React.FC = () => {
<span>{selectedOrder.totalAmount.toFixed(2)}</span>
</div>
</div>
<button className="repeat-order-btn" onClick={() => handleRepeatOrder(selectedOrder)}>
<RefreshCcw size={18} /> Repeat this order
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Stock Alert Dialog */}
<AnimatePresence>
{showStockAlert && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="stock-alert-overlay"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="stock-alert-modal"
>
<div className="alert-icon-wrapper">
<AlertCircle size={32} />
</div>
<h3>Items Unavailable</h3>
<p>Some items from your previous order are currently out of stock or unavailable.</p>
<div className="unavailable-items-list">
{unavailableItems.map((item, idx) => (
<div key={idx} className="unavailable-item-chip">
<X size={14} /> {item}
</div>
))}
</div>
<div className="alert-actions">
{pendingItems.length > 0 ? (
<button className="btn-proceed" onClick={handleProceedPartial}>
Proceed without these items
</button>
) : (
<div style={{ marginBottom: '12px', fontSize: '13px', color: '#e11d48', fontWeight: '600' }}>
All items in this order are currently unavailable.
</div>
)}
<button className="btn-discard" onClick={() => setShowStockAlert(false)}>
Discard
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<BottomNav />
</div>

View File

@@ -205,3 +205,140 @@
font-size: 0.75rem;
color: var(--text-light);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal-container {
background: var(--surface);
width: 100%;
max-width: 400px;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
animation: modalIn 0.3s ease-out;
}
@keyframes modalIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
padding: 24px 24px 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-dark);
}
.close-btn {
background: var(--bg);
border: none;
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mid);
cursor: pointer;
}
.modal-body {
padding: 12px 24px 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 700;
color: var(--text-mid);
margin-bottom: 8px;
margin-left: 4px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 16px;
color: var(--primary);
}
.form-group input {
width: 100%;
padding: 16px 16px 16px 48px;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
color: var(--text-dark);
transition: all 0.2s;
}
.form-group input:focus {
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 4px var(--primary-light);
outline: none;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
gap: 12px;
}
.btn-cancel {
flex: 1;
padding: 16px;
background: var(--bg);
color: var(--text-mid);
border-radius: 16px;
font-weight: 700;
border: 1px solid var(--border);
}
.btn-save {
flex: 2;
padding: 16px;
background: var(--primary);
color: white;
border-radius: 16px;
font-weight: 700;
box-shadow: 0 4px 15px rgba(35, 22, 81, 0.2);
}
.btn-save:disabled {
opacity: 0.7;
cursor: not-allowed;
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShoppingBag,
@@ -7,16 +7,33 @@ import {
LogOut,
ChevronRight,
Phone,
ShieldCheck
ShieldCheck,
User,
X,
User as UserIcon
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { motion, AnimatePresence } from 'framer-motion';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import './ProfileScreen.css';
const ProfileScreen: React.FC = () => {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { user, logout, updateProfile } = useAuth();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editName, setEditName] = useState(user?.name || '');
const [editMobile, setEditMobile] = useState(user?.mobileNumber || '');
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (user) {
setEditName(user.name);
setEditMobile(user.mobileNumber);
}
}, [user]);
const handleLogout = () => {
if (window.confirm('Are you sure you want to logout?')) {
@@ -25,6 +42,39 @@ const ProfileScreen: React.FC = () => {
}
};
const handleEditClick = () => {
setEditName(user?.name || '');
setEditMobile(user?.mobileNumber || '');
setError(null);
setIsEditModalOpen(true);
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!editName.trim() || !editMobile.trim()) {
setError('Name and mobile number are required');
return;
}
if (!/^[0-9]{10}$/.test(editMobile)) {
setError('Mobile number must be 10 digits');
return;
}
setIsUpdating(true);
setError(null);
const result = await updateProfile(editName, editMobile);
setIsUpdating(false);
if (result.success) {
setIsEditModalOpen(false);
// Optional: Show success message
} else {
setError(result.message);
}
};
const menuItems = [
{ icon: ShoppingBag, label: 'My Orders', sub: 'View order history', path: '/orders' },
{ icon: ShieldCheck, label: 'Account Security', sub: 'Change Security PIN', path: '/change-pin' },
@@ -49,10 +99,9 @@ const ProfileScreen: React.FC = () => {
<h2 className="user-name">{user?.name || 'User Name'}</h2>
<div className="user-contact">
<span className="contact-item"><Phone size={14} /> {user?.mobileNumber || '+91 XXXXX XXXXX'}</span>
{/* <span className="contact-item"><Mail size={14} /> user@example.com</span> */}
</div>
</div>
<button className="edit-btn">Edit</button>
<button className="edit-btn" onClick={handleEditClick}>Edit</button>
</div>
</section>
@@ -98,6 +147,70 @@ const ProfileScreen: React.FC = () => {
</section>
</main>
{/* Edit Profile Modal */}
<AnimatePresence>
{isEditModalOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal-overlay"
>
<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 }}
className="modal-container"
>
<div className="modal-header">
<h2>Edit Profile</h2>
<button type="button" className="close-btn" onClick={() => setIsEditModalOpen(false)}>
<X size={20} />
</button>
</div>
<form onSubmit={handleUpdateProfile}>
<div className="modal-body">
{error && <div className="error-message" style={{ color: '#ff4d4f', fontSize: '0.85rem', marginBottom: '16px', fontWeight: '600' }}>{error}</div>}
<div className="form-group">
<label>Full Name</label>
<div className="input-wrapper">
<UserIcon className="input-icon" size={20} />
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Enter your name"
/>
</div>
</div>
<div className="form-group">
<label>Mobile Number</label>
<div className="input-wrapper">
<Phone className="input-icon" size={20} />
<input
type="tel"
value={editMobile}
onChange={(e) => setEditMobile(e.target.value)}
placeholder="10-digit mobile number"
maxLength={10}
/>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-cancel" onClick={() => setIsEditModalOpen(false)}>Cancel</button>
<button type="submit" className="btn-save" disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Save Changes'}
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<BottomNav />
</div>
);