diff --git a/backend/auth-test-server.js b/backend/auth-test-server.js new file mode 100644 index 00000000..8b01c049 --- /dev/null +++ b/backend/auth-test-server.js @@ -0,0 +1,160 @@ +import express from "express"; +import cors from "cors"; +import cookieParser from 'cookie-parser'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import "dotenv/config"; + +const app = express(); +const port = 4000; + +// Middleware +app.use(express.json()); +app.use(cookieParser()); +app.use(cors({ + origin: ["http://localhost:5173", "http://localhost:3000"], + credentials: true +})); + +// Simple in-memory user storage for testing (replace with DB later) +let users = []; + +const generateToken = (id) => { + return jwt.sign({ id }, process.env.JWT_SECRET || "fallback-secret", { expiresIn: '7d' }); +}; + +// Test registration endpoint +app.post('/api/auth/register', async (req, res) => { + try { + const { name, email, password } = req.body; + + console.log('๐Ÿ“ Registration attempt:', { name, email, passwordLength: password?.length }); + + // Validate required fields + if (!name || !email || !password) { + console.log('โŒ Missing required fields'); + return res.status(400).json({ message: 'All fields are required' }); + } + + // Check if user already exists (in memory) + const userExists = users.find(user => user.email === email); + if (userExists) { + console.log('โŒ User already exists:', email); + return res.status(400).json({ message: 'User already exists' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user (in memory) + const newUser = { + _id: Date.now().toString(), + name, + email, + password: hashedPassword, + createdAt: new Date() + }; + + users.push(newUser); + console.log('โœ… User created successfully:', email); + + // Generate JWT + const token = generateToken(newUser._id); + + // Set cookie + res.cookie('token', token, { + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + // Return success + res.status(201).json({ + user: { _id: newUser._id, name: newUser.name, email: newUser.email }, + token: token, + message: 'Registration successful', + }); + + } catch (error) { + console.error('๐Ÿ’ฅ Registration error:', error); + res.status(500).json({ + message: 'Server error during registration', + error: error.message + }); + } +}); + +// Test login endpoint +app.post('/api/auth/login', async (req, res) => { + try { + const { email, password } = req.body; + + console.log('๐Ÿ“ Login attempt:', { email }); + + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required' }); + } + + // Find user (in memory) + const user = users.find(u => u.email === email); + if (!user) { + console.log('โŒ User not found:', email); + return res.status(401).json({ message: 'Invalid email or password' }); + } + + // Compare password + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + console.log('โŒ Invalid password for:', email); + return res.status(401).json({ message: 'Invalid email or password' }); + } + + // Generate JWT + const token = generateToken(user._id); + + // Set cookie + res.cookie('token', token, { + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + console.log('โœ… Login successful:', email); + + res.json({ + user: { _id: user._id, name: user.name, email: user.email }, + token: token, + message: 'Login successful', + }); + + } catch (error) { + console.error('๐Ÿ’ฅ Login error:', error); + res.status(500).json({ + message: 'Server error during login', + error: error.message + }); + } +}); + +// Test endpoint +app.get('/', (req, res) => { + res.json({ + message: '๐Ÿš€ Test Auth Server is working!', + users: users.length, + timestamp: new Date().toISOString() + }); +}); + +// Get all users (for testing) +app.get('/api/users', (req, res) => { + res.json({ + users: users.map(u => ({ id: u._id, name: u.name, email: u.email })), + count: users.length + }); +}); + +app.listen(port, () => { + console.log(`๐Ÿงช Test server running on port ${port}`); + console.log(`๐Ÿ“ Test at: http://localhost:${port}`); + console.log(`๐Ÿ‘ฅ Register at: http://localhost:${port}/api/auth/register`); +}); \ No newline at end of file diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 7209eaea..a70c629a 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -7,65 +7,114 @@ const generateToken = (id) => { }; export const registerUser = async (req, res) => { - const { name, email, password } = req.body; - - // Check if user already exists - const userExists = await User.findOne({ email }); - if (userExists) { - return res.status(400).json({ message: 'User already exists' }); + try { + const { name, email, password } = req.body; + + // Validate required fields + if (!name || !email || !password) { + return res.status(400).json({ message: 'All fields are required' }); + } + + // Check if user already exists + const userExists = await User.findOne({ email }); + if (userExists) { + return res.status(400).json({ message: 'User already exists' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with initial loyalty points and welcome achievement + const user = await User.create({ + name, + email, + password: hashedPassword, + loyaltyPoints: 500, + totalPointsEarned: 500, + achievements: [{ + id: 'welcome', + name: 'Welcome to Foodie!', + unlockedAt: new Date() + }] + }); + + // Generate JWT + const token = generateToken(user._id); + + // Set JWT in secure, HTTP-only cookie + res.cookie('token', token, { + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + res.status(201).json({ + user: { + _id: user._id, + name: user.name, + email: user.email, + loyaltyPoints: user.loyaltyPoints, + totalPointsEarned: user.totalPointsEarned, + achievements: user.achievements, + rewardHistory: user.rewardHistory + }, + token: token, + message: 'Registration successful! Welcome bonus: 500 loyalty points added! ๐ŸŽ‰', + }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ message: 'Server error during registration' }); } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user - const user = await User.create({ name, email, password: hashedPassword }); - - // Generate JWT - const token = generateToken(user._id); - - // Set JWT in secure, HTTP-only cookie - res.cookie('token', token, { - httpOnly: true, // Prevent JS access (XSS protection) - sameSite: 'strict', // CSRF protection - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days - }); - - res.status(201).json({ - user: { _id: user._id, name: user.name, email: user.email }, - message: 'Registration successful', - }); }; export const loginUser = async (req, res) => { - const { email, password } = req.body; - - // Find user - const user = await User.findOne({ email }); - if (!user) { - return res.status(401).json({ message: 'User not found' }); + try { + const { email, password } = req.body; + + // Validate required fields + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required' }); + } + + // Find user + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + // Compare password + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + // Generate JWT + const token = generateToken(user._id); + + // Set JWT in secure, HTTP-only cookie + res.cookie('token', token, { + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.json({ + user: { + _id: user._id, + name: user.name, + email: user.email, + loyaltyPoints: user.loyaltyPoints || 0, + totalPointsEarned: user.totalPointsEarned || 0, + achievements: user.achievements || [], + rewardHistory: user.rewardHistory || [] + }, + token: token, + message: 'Login successful', + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ message: 'Server error during login' }); } - - // Compare password - const isMatch = await bcrypt.compare(password, user.password); - if (!isMatch) { - return res.status(401).json({ message: 'Invalid password' }); - } - - // Generate JWT - const token = generateToken(user._id); - - // Set JWT in secure, HTTP-only cookie - res.cookie('token', token, { - httpOnly: true, - sameSite: 'strict', - maxAge: 7 * 24 * 60 * 60 * 1000, - }); - - res.json({ - user: { _id: user._id, name: user.name, email: user.email }, - message: 'Login successful', - }); }; export const logoutUser = async (req, res) => { @@ -78,4 +127,4 @@ export const logoutUser = async (req, res) => { }); res.status(200).json({ message: 'User logged out successfully' }); -}; +}; \ No newline at end of file diff --git a/backend/controllers/foodController.js b/backend/controllers/foodController.js index 1445165a..252e043f 100644 --- a/backend/controllers/foodController.js +++ b/backend/controllers/foodController.js @@ -15,6 +15,7 @@ const addFood = async (req, res) => { price: body.price, description: body.description, category: body.category, + foodType: body.foodType || 'main', // Default to 'main' if not provided restaurantId: body.restaurantId, image: req.file?.filename, }); @@ -52,5 +53,50 @@ const removeFood = async (req, res) => { } }; +// Get all available food types/categories +const getFoodTypes = async (req, res) => { + try { + const foodTypes = ['appetizer', 'main', 'dessert', 'beverage', 'snack', 'side']; + res.status(200).json({ success: true, foodTypes }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + +// Get foods by food type +const getFoodsByType = async (req, res) => { + try { + const { foodType } = req.params; + const { restaurantId } = req.query; + + let query = { foodType }; + if (restaurantId) { + query.restaurantId = restaurantId; + } + + const foods = await Food.find(query).populate('restaurantId', 'name'); + res.status(200).json({ success: true, foods }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + +// Get all foods with optional filtering +const getAllFoods = async (req, res) => { + try { + const { foodType, category, restaurantId } = req.query; + let query = {}; + + if (foodType) query.foodType = foodType; + if (category) query.category = category; + if (restaurantId) query.restaurantId = restaurantId; + + const foods = await Food.find(query).populate('restaurantId', 'name'); + res.status(200).json({ success: true, foods }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + // โœ… Export all at once (no duplicates) -export { addFood, getFoodByRestaurant, removeFood }; +export { addFood, getFoodByRestaurant, removeFood, getFoodTypes, getFoodsByType, getAllFoods }; diff --git a/backend/models/userModel.js b/backend/models/userModel.js index 7ec70e93..97789d03 100644 --- a/backend/models/userModel.js +++ b/backend/models/userModel.js @@ -6,7 +6,25 @@ const userSchema = new mongoose.Schema({ password: { type: String, required: true }, role: { type: String, enum: ["user", "admin"], default: "user" }, favoriteRestaurant: { type: mongoose.Schema.Types.ObjectId, ref: "Restaurant" }, - favoriteFoods: [{ type: mongoose.Schema.Types.ObjectId, ref: "food" }] + favoriteFoods: [{ type: mongoose.Schema.Types.ObjectId, ref: "food" }], + + // Loyalty Points System + loyaltyPoints: { type: Number, default: 500 }, // Give 500 points initially + totalPointsEarned: { type: Number, default: 500 }, // Track lifetime points earned + achievements: [{ + id: String, + name: String, + unlockedAt: { type: Date, default: Date.now } + }], + rewardHistory: [{ + id: String, + rewardId: String, + rewardName: String, + pointsCost: Number, + redeemedAt: { type: Date, default: Date.now }, + used: { type: Boolean, default: false }, + usedAt: Date + }] }, { timestamps: true }); diff --git a/backend/package.json b/backend/package.json index 01102f57..bcccfe41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "server": "nodemon server.js", - "start": "node server.js" + "start": "node server.js", + "migrate:loyalty": "node utils/migrateLoyaltyPoints.js" }, "author": "", "license": "ISC", diff --git a/backend/routes/foodRoute.js b/backend/routes/foodRoute.js index 192a6667..0cbbfdff 100644 --- a/backend/routes/foodRoute.js +++ b/backend/routes/foodRoute.js @@ -1,5 +1,5 @@ import express from "express" -import { addFood, getFoodByRestaurant , removeFood} from "../controllers/foodController.js" +import { addFood, getFoodByRestaurant , removeFood, getFoodTypes, getFoodsByType, getAllFoods} from "../controllers/foodController.js" import multer from "multer" const foodRouter = express.Router(); @@ -20,4 +20,7 @@ const upload = multer({storage:storage}) foodRouter.post("/add", upload.single('image'), addFood); foodRouter.post("/remove", removeFood) foodRouter.get("/restaurant/:restaurantId", getFoodByRestaurant); +foodRouter.get("/types", getFoodTypes); // Get all available food types +foodRouter.get("/type/:foodType", getFoodsByType); // Get foods by specific type +foodRouter.get("/all", getAllFoods); // Get all foods with optional filtering export default foodRouter; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 90e679ae..35488360 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,16 +8,13 @@ import reviewRoutes from "./routes/reviewRoutes.js"; import paymentRoute from "./routes/paymentRoute.js"; import restaurantRoutes from "./routes/restaurantRoutes.js"; import authRoutes from './routes/authRoute.js'; - import userRoutes from './routes/userRoute.js'; - import cookieParser from 'cookie-parser'; - - import "dotenv/config"; + // app config const app = express(); -const port = 4000; +const port = process.env.PORT || 4000; // middleware app.use(express.json()); @@ -25,13 +22,16 @@ app.use(cookieParser()); // CORS setup for cookies app.use(cors({ - origin: "http://localhost:5173", // frontend URL + origin: ["http://localhost:5173", "http://localhost:3000"], // frontend URLs credentials: true // allow cookies to be sent })); - // db connection -await connectDB(); +try { + await connectDB(); +} catch (error) { + console.log('Database connection failed:', error.message); +} // api endpoints app.use("/api/food", foodRouter); @@ -43,11 +43,19 @@ app.use("/api/restaurant", restaurantRoutes); app.use("/api/auth", authRoutes); app.use("/api/user", userRoutes); - app.get("/", (req, res) => { - res.send("api working"); + res.json({ + message: "Foodie API is working!", + status: "success" + }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('โŒ Server Error:', err.stack); + res.status(500).json({ message: 'Something went wrong!' }); }); app.listen(port, () => { - console.log(`server started on port ${port}`); + console.log(`Server started on port ${port}`); }); diff --git a/backend/utils/migrateLoyaltyPoints.js b/backend/utils/migrateLoyaltyPoints.js new file mode 100644 index 00000000..dbdc9c80 --- /dev/null +++ b/backend/utils/migrateLoyaltyPoints.js @@ -0,0 +1,84 @@ +import mongoose from 'mongoose'; +import User from '../models/userModel.js'; +import { connectDB } from '../config/db.js'; +import 'dotenv/config'; + +/** + * Migration script to add loyalty points to existing users + * Run this once to give all existing users 500 initial points + */ +const migrateLoyaltyPoints = async () => { + try { + console.log('๐Ÿš€ Starting loyalty points migration...'); + + // Connect to database + await connectDB(); + console.log('โœ… Connected to database'); + + // Find all users without loyalty points + const usersToUpdate = await User.find({ + $or: [ + { loyaltyPoints: { $exists: false } }, + { loyaltyPoints: { $eq: null } }, + { totalPointsEarned: { $exists: false } } + ] + }); + + console.log(`๐Ÿ“Š Found ${usersToUpdate.length} users to update`); + + if (usersToUpdate.length === 0) { + console.log('โœ… All users already have loyalty points'); + return; + } + + // Update users with initial loyalty points + const updateResult = await User.updateMany( + { + $or: [ + { loyaltyPoints: { $exists: false } }, + { loyaltyPoints: { $eq: null } }, + { totalPointsEarned: { $exists: false } } + ] + }, + { + $set: { + loyaltyPoints: 500, + totalPointsEarned: 500, + achievements: [{ + id: 'legacy_welcome', + name: 'Loyalty Program Member', + unlockedAt: new Date() + }], + rewardHistory: [] + } + } + ); + + console.log(`โœ… Updated ${updateResult.modifiedCount} users with 500 loyalty points`); + console.log('๐ŸŽ‰ Migration completed successfully!'); + + // Show some stats + const totalUsers = await User.countDocuments(); + const usersWithPoints = await User.countDocuments({ loyaltyPoints: { $gte: 0 } }); + + console.log(`๐Ÿ“ˆ Summary:`); + console.log(` Total users: ${totalUsers}`); + console.log(` Users with loyalty points: ${usersWithPoints}`); + console.log(` Coverage: ${((usersWithPoints / totalUsers) * 100).toFixed(1)}%`); + + } catch (error) { + console.error('โŒ Migration failed:', error); + } finally { + // Close database connection + await mongoose.connection.close(); + console.log('๐Ÿ”Œ Database connection closed'); + process.exit(0); + } +}; + +// Run migration if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + migrateLoyaltyPoints(); +} + +export default migrateLoyaltyPoints; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 386c35f6..21b09922 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext } from "react"; import Navbar from "./components/Navbar/Navbar"; import { Routes, Route } from "react-router-dom"; import Home from "./pages/Home/Home"; @@ -8,6 +8,7 @@ import Footer from "./components/Footer/Footer"; import AppDownload from "./components/AppDownlad/AppDownload"; import LoginPopup from "./components/LoginPopup/LoginPopup"; import ThemeContextProvider from "./components/context/ThemeContext"; +import LoyaltyContextProvider from "./components/context/LoyaltyContext"; import FoodDetail from "./components/FoodDetail/FoodDetail"; import CartSummaryBar from "./components/CartSummaryBar/CartSummaryBar"; import ScrollToTopButton from "./components/ScrollToTopButton/ScrollToTopButton"; @@ -22,21 +23,91 @@ import LoadingAnimation from "./components/LoadingAnimation"; import ScrollToTop from "../utility/ScrollToTop"; import "./components/FoodDetail/print.css"; import NotFound from "./pages/Notfound"; -import StoreContextProvider from "./components/context/StoreContext"; +import StoreContextProvider, { StoreContext } from "./components/context/StoreContext"; import ScrollToBottom from "./components/ScrollToBottomButton/ScrollToBottomButton"; import ReferralProgram from "./components/Referrals/ReferralProgram"; import AboutUs from "./components/Aboutus/Aboutus"; import FAQ from "./components/FAQ/FAQ"; import Privacy from "./components/Privacy/privacy"; import FeedbackReviews from "./components/FeedbackReviews/FeedbackReviews"; +import Rewards from "./pages/Rewards/Rewards"; +import AchievementNotification from "./components/AchievementNotification/AchievementNotification"; -const App = () => { +const AppContent = () => { const [showLogin, setShowLogin] = useState(false); + const { isAuthenticated } = useContext(StoreContext); + + return ( + <> + + {showLogin && } + +
+ + + + + + } /> + } /> + + ) : ( +
+

+ Please Log In To Proceed +

+

+ Your journey continues after login ๐Ÿ” +

+
+ ) + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> +
+ + {/* floating button */} + + + + + {/* โœ… Footer now contains FAQ */} +
+ {/* */} + {/*
*/} + + {/* AI Food Assistant */} + {/* Achievement popup notifications */} +
+ + ); +}; + +const App = () => { const [loading, setLoading] = useState(true); - const [isLoggedIn, setIsLoggedIn] = useState(() => { - // Check for either authToken or user in localStorage - return !!localStorage.getItem("authToken") || !!localStorage.getItem("user"); - }); useEffect(() => { const timer = setTimeout(() => setLoading(false), 3000); @@ -50,68 +121,9 @@ const App = () => { return ( - {/* โœ… Wrap the app with StoreContextProvider */} - - {showLogin && } - -
- - - - - - } /> - } /> - - ) : ( -
-

- Please Log In To Proceed -

-

- Your journey continues after login ๐Ÿ” -

-
- ) - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> -
- - {/* floating button */} - - - - - {/* โœ… Footer now contains FAQ */} -
- {/* */} - {/*
*/} - - {/* AI Food Assistant */} -
+ + +
); diff --git a/frontend/src/components/AchievementNotification/AchievementNotification.css b/frontend/src/components/AchievementNotification/AchievementNotification.css new file mode 100644 index 00000000..6b3a5a7e --- /dev/null +++ b/frontend/src/components/AchievementNotification/AchievementNotification.css @@ -0,0 +1,268 @@ +/* Achievement Notification Styles */ +.achievement-notification { + position: fixed; + top: 100px; + right: -400px; + width: 350px; + z-index: 10000; + transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +.achievement-notification.show { + right: 20px; +} + +.notification-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 16px; + padding: 1.5rem; + color: white; + box-shadow: + 0 20px 40px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; + animation: celebrateEntry 0.8s ease-out; +} + +@keyframes celebrateEntry { + 0% { + transform: scale(0.8) rotate(-10deg); + opacity: 0; + } + 50% { + transform: scale(1.1) rotate(5deg); + } + 100% { + transform: scale(1) rotate(0deg); + opacity: 1; + } +} + +.close-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: all 0.3s ease; +} + +.close-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.1); +} + +.achievement-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.trophy-icon { + color: #ffd700; + animation: bounce 2s infinite; +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-8px); + } + 60% { + transform: translateY(-4px); + } +} + +.achievement-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: bold; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} + +.achievement-details { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.achievement-badge { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + border: 3px solid rgba(255, 255, 255, 0.3); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +} + +.achievement-emoji { + font-size: 1.5rem; +} + +.achievement-info { + flex: 1; +} + +.achievement-info h4 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + font-weight: 600; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); +} + +.achievement-info p { + margin: 0 0 0.75rem 0; + opacity: 0.9; + font-size: 0.9rem; + line-height: 1.4; +} + +.points-earned { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.2); + padding: 0.5rem 0.75rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 500; +} + +.star-icon { + color: #ffd700; + animation: sparkle 1.5s ease-in-out infinite; +} + +@keyframes sparkle { + 0%, 100% { + transform: scale(1) rotate(0deg); + } + 50% { + transform: scale(1.2) rotate(180deg); + } +} + +/* Celebration Effects */ +.celebration-effects { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; +} + +.confetti { + position: absolute; + width: 8px; + height: 8px; + background: #ffd700; + animation: confettiFall 3s linear infinite; +} + +.confetti:nth-child(1) { + left: 10%; + animation-delay: 0s; + background: #ff6b6b; +} + +.confetti:nth-child(2) { + left: 30%; + animation-delay: 0.5s; + background: #4ecdc4; +} + +.confetti:nth-child(3) { + left: 50%; + animation-delay: 1s; + background: #45b7d1; +} + +.confetti:nth-child(4) { + left: 70%; + animation-delay: 1.5s; + background: #96ceb4; +} + +.confetti:nth-child(5) { + left: 90%; + animation-delay: 2s; + background: #feca57; +} + +@keyframes confettiFall { + 0% { + transform: translateY(-100px) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(400px) rotate(720deg); + opacity: 0; + } +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .achievement-notification { + width: 320px; + top: 80px; + } + + .achievement-notification.show { + right: 10px; + } + + .notification-content { + padding: 1.25rem; + } + + .achievement-details { + flex-direction: column; + text-align: center; + gap: 0.75rem; + } + + .achievement-badge { + align-self: center; + } +} + +@media (max-width: 480px) { + .achievement-notification { + width: calc(100vw - 20px); + left: 10px; + right: auto; + } + + .achievement-notification.show { + right: auto; + } +} \ No newline at end of file diff --git a/frontend/src/components/AchievementNotification/AchievementNotification.jsx b/frontend/src/components/AchievementNotification/AchievementNotification.jsx new file mode 100644 index 00000000..59df6652 --- /dev/null +++ b/frontend/src/components/AchievementNotification/AchievementNotification.jsx @@ -0,0 +1,104 @@ +import React, { useContext, useState, useEffect } from 'react'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { StoreContext } from '../context/StoreContext'; +import { X, Trophy, Star } from 'lucide-react'; +import './AchievementNotification.css'; + +const AchievementNotification = () => { + const { isAuthenticated } = useContext(StoreContext); + const { achievements } = useContext(LoyaltyContext); + const [showNotification, setShowNotification] = useState(false); + const [currentAchievement, setCurrentAchievement] = useState(null); + const [notificationQueue, setNotificationQueue] = useState([]); + + useEffect(() => { + if (!isAuthenticated) return; + + // Check for new achievements (compare with localStorage) + const lastShownAchievements = JSON.parse( + localStorage.getItem('lastShownAchievements') || '[]' + ); + + const newAchievements = achievements.filter( + achievement => !lastShownAchievements.some(shown => shown.id === achievement.id) + ); + + if (newAchievements.length > 0) { + setNotificationQueue(newAchievements); + localStorage.setItem('lastShownAchievements', JSON.stringify(achievements)); + } + }, [achievements, isAuthenticated]); + + useEffect(() => { + if (notificationQueue.length > 0 && !showNotification) { + const nextAchievement = notificationQueue[0]; + setCurrentAchievement(nextAchievement); + setShowNotification(true); + + // Auto-hide after 5 seconds + const timer = setTimeout(() => { + closeNotification(); + }, 5000); + + return () => clearTimeout(timer); + } + }, [notificationQueue, showNotification]); + + const closeNotification = () => { + setShowNotification(false); + setCurrentAchievement(null); + + // Remove the shown achievement from queue and show next if any + setTimeout(() => { + setNotificationQueue(prev => { + const newQueue = prev.slice(1); + return newQueue; + }); + }, 300); // Wait for animation to complete + }; + + if (!showNotification || !currentAchievement) { + return null; + } + + return ( +
+
+ + +
+ +

Achievement Unlocked!

+
+ +
+
+ {currentAchievement.icon} +
+ +
+

{currentAchievement.name}

+

{currentAchievement.description}

+ +
+ + +{currentAchievement.points} points earned! +
+
+
+ +
+
+
+
+
+
+
+
+
+ ); +}; + +export default AchievementNotification; \ No newline at end of file diff --git a/frontend/src/components/CartPointsPreview/CartPointsPreview.css b/frontend/src/components/CartPointsPreview/CartPointsPreview.css new file mode 100644 index 00000000..b3c4a85a --- /dev/null +++ b/frontend/src/components/CartPointsPreview/CartPointsPreview.css @@ -0,0 +1,261 @@ +.cart-points-preview { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 16px; + padding: 20px; + margin: 15px 0; + color: white; + box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3); + position: relative; + overflow: hidden; +} + +.cart-points-preview::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + z-index: 1; +} + +.cart-points-preview > * { + position: relative; + z-index: 2; +} + +.points-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; +} + +.points-header h3 { + margin: 0; + font-size: 1.3rem; + font-weight: 600; +} + +.points-earning { + margin-bottom: 20px; +} + +.earning-card { + background: rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.earning-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.earning-label { + font-size: 0.95rem; + opacity: 0.9; +} + +.earning-points { + font-size: 1.4rem; + font-weight: 700; + color: #ffd23f; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.points-calculation { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.85rem; + opacity: 0.8; +} + +.current-rewards, +.upcoming-rewards { + margin-bottom: 20px; +} + +.current-rewards h4, +.upcoming-rewards h4 { + margin: 0 0 10px 0; + font-size: 1rem; + font-weight: 600; +} + +.rewards-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reward-item { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.1); + padding: 10px 12px; + border-radius: 8px; + backdrop-filter: blur(5px); + position: relative; +} + +.reward-item.available { + border: 1px solid rgba(255, 215, 63, 0.5); + background: rgba(255, 215, 63, 0.15); +} + +.reward-item.upcoming { + border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); +} + +.reward-info { + display: flex; + flex-direction: column; + flex: 1; +} + +.reward-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 2px; +} + +.reward-cost { + font-size: 0.8rem; + opacity: 0.8; +} + +.unlock-badge { + background: #ff6b35; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.more-rewards { + text-align: center; + font-size: 0.85rem; + opacity: 0.8; + font-style: italic; + padding: 8px; +} + +.next-milestone { + margin-bottom: 20px; +} + +.milestone-progress { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 12px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.85rem; +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #ffd23f, #ff6b35); + border-radius: 3px; + transition: width 0.5s ease; +} + +.points-summary { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 15px; + margin-bottom: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.9rem; +} + +.summary-row:last-child { + margin-bottom: 0; +} + +.summary-row.total { + font-weight: 600; + font-size: 1rem; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.2); + color: #ffd23f; +} + +.points-cta { + text-align: center; + margin-top: 10px; +} + +.points-cta p { + margin: 0; + font-size: 0.85rem; + opacity: 0.9; +} + +/* Dark theme support */ +.cart-points-preview.dark { + background: linear-gradient(135deg, #2d1810 0%, #1a1a1a 100%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.cart-points-preview.dark::before { + background: rgba(255, 255, 255, 0.05); +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .cart-points-preview { + padding: 15px; + margin: 10px 0; + } + + .earning-info { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } + + .progress-info { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } + + .summary-row { + font-size: 0.85rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/CartPointsPreview/CartPointsPreview.jsx b/frontend/src/components/CartPointsPreview/CartPointsPreview.jsx new file mode 100644 index 00000000..dcfd1c46 --- /dev/null +++ b/frontend/src/components/CartPointsPreview/CartPointsPreview.jsx @@ -0,0 +1,142 @@ +import React, { useContext } from 'react'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { StoreContext } from '../context/StoreContext'; +import { ThemeContext } from '../context/ThemeContext'; +import { Coins, Gift, Star, Zap } from 'lucide-react'; +import './CartPointsPreview.css'; + +const CartPointsPreview = ({ cartTotal }) => { + const { isAuthenticated } = useContext(StoreContext); + const { theme } = useContext(ThemeContext); + const { + userPoints, + calculateOrderPoints, + REWARDS_CATALOG, + getAvailableRewards + } = useContext(LoyaltyContext); + + if (!isAuthenticated || cartTotal === 0) { + return null; + } + + const pointsToEarn = calculateOrderPoints(cartTotal); + const currentPoints = userPoints || 0; + const totalPointsAfterOrder = currentPoints + pointsToEarn; + + // Find rewards user can afford with current points + const affordableRewards = REWARDS_CATALOG.filter(reward => + currentPoints >= reward.pointsCost + ); + + // Find rewards user will be able to afford after this order + const upcomingRewards = REWARDS_CATALOG.filter(reward => + currentPoints < reward.pointsCost && totalPointsAfterOrder >= reward.pointsCost + ); + + // Find the next milestone reward + const nextMilestone = REWARDS_CATALOG + .filter(reward => totalPointsAfterOrder < reward.pointsCost) + .sort((a, b) => a.pointsCost - b.pointsCost)[0]; + + return ( +
+
+ +

Loyalty Points

+
+ +
+
+
+ You'll earn + +{pointsToEarn} points +
+
+ + ${cartTotal} = {pointsToEarn} points +
+
+
+ + {affordableRewards.length > 0 && ( +
+

๐ŸŽ Available Now ({currentPoints} points)

+
+ {affordableRewards.slice(0, 2).map((reward) => ( +
+ +
+ {reward.name} + {reward.pointsCost} points +
+
+ ))} + {affordableRewards.length > 2 && ( +
+ +{affordableRewards.length - 2} more available +
+ )} +
+
+ )} + + {upcomingRewards.length > 0 && ( +
+

๐ŸŒŸ You'll Unlock After This Order

+
+ {upcomingRewards.slice(0, 2).map((reward) => ( +
+ +
+ {reward.name} + {reward.pointsCost} points +
+
NEW!
+
+ ))} +
+
+ )} + + {nextMilestone && ( +
+
+
+ Next reward: {nextMilestone.name} + {nextMilestone.pointsCost - totalPointsAfterOrder} points away +
+
+
+
+
+
+ )} + +
+
+ Current points: + {currentPoints} +
+
+ Points from this order: + +{pointsToEarn} +
+
+ Total after order: + {totalPointsAfterOrder} +
+
+ +
+

๐Ÿ’ก Visit Rewards to redeem your points anytime!

+
+
+ ); +}; + +export default CartPointsPreview; \ No newline at end of file diff --git a/frontend/src/components/CategoryFilter/CategoryFilter.css b/frontend/src/components/CategoryFilter/CategoryFilter.css new file mode 100644 index 00000000..262e53f5 --- /dev/null +++ b/frontend/src/components/CategoryFilter/CategoryFilter.css @@ -0,0 +1,116 @@ +.category-filter { + margin: 20px 0; + padding: 20px; + background: #f8f9fa; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.category-filter-title { + font-size: 1.5rem; + font-weight: 600; + color: #333; + margin-bottom: 15px; + text-align: center; +} + +.category-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; + align-items: center; +} + +.category-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border: 2px solid #e0e0e0; + border-radius: 25px; + background: white; + color: #666; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + font-size: 0.9rem; + min-width: 120px; + justify-content: center; +} + +.category-btn:hover { + border-color: #ff6b35; + color: #ff6b35; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.2); +} + +.category-btn.active { + background: linear-gradient(135deg, #ff6b35, #f7931e); + color: white; + border-color: #ff6b35; + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); +} + +.category-btn.active:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 107, 53, 0.4); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .category-filter { + margin: 15px 0; + padding: 15px; + } + + .category-filter-title { + font-size: 1.3rem; + margin-bottom: 12px; + } + + .category-buttons { + gap: 8px; + } + + .category-btn { + padding: 10px 16px; + min-width: 100px; + font-size: 0.85rem; + } +} + +@media (max-width: 480px) { + .category-buttons { + flex-direction: column; + align-items: stretch; + } + + .category-btn { + min-width: auto; + width: 100%; + max-width: 200px; + margin: 0 auto; + } +} + +/* Dark theme support */ +[data-theme="dark"] .category-filter { + background: #2a2a2a; +} + +[data-theme="dark"] .category-filter-title { + color: #fff; +} + +[data-theme="dark"] .category-btn { + background: #3a3a3a; + border-color: #555; + color: #ccc; +} + +[data-theme="dark"] .category-btn:hover { + border-color: #ff6b35; + color: #ff6b35; +} \ No newline at end of file diff --git a/frontend/src/components/CategoryFilter/CategoryFilter.jsx b/frontend/src/components/CategoryFilter/CategoryFilter.jsx new file mode 100644 index 00000000..7612a238 --- /dev/null +++ b/frontend/src/components/CategoryFilter/CategoryFilter.jsx @@ -0,0 +1,45 @@ +import React, { useState, useEffect } from 'react'; +import './CategoryFilter.css'; +import { Utensils, Coffee, Cake, Coffee as Beverage, Apple, ChefHat } from 'lucide-react'; + +const CategoryFilter = ({ onCategoryChange, selectedCategory }) => { + const [categories, setCategories] = useState([]); + + // Define food type categories with icons + const foodTypes = [ + { id: 'all', name: 'All', icon: ChefHat }, + { id: 'appetizer', name: 'Appetizers', icon: Apple }, + { id: 'main', name: 'Main Course', icon: Utensils }, + { id: 'dessert', name: 'Desserts', icon: Cake }, + { id: 'beverage', name: 'Beverages', icon: Beverage }, + { id: 'snack', name: 'Snacks', icon: Apple }, + { id: 'side', name: 'Sides', icon: Utensils } + ]; + + const handleCategoryClick = (categoryId) => { + onCategoryChange(categoryId); + }; + + return ( +
+

Food Categories

+
+ {foodTypes.map((type) => { + const IconComponent = type.icon; + return ( + + ); + })} +
+
+ ); +}; + +export default CategoryFilter; \ No newline at end of file diff --git a/frontend/src/components/ExploreMenu/ExploreMenu.jsx b/frontend/src/components/ExploreMenu/ExploreMenu.jsx index b78f3b2a..0932b0a2 100644 --- a/frontend/src/components/ExploreMenu/ExploreMenu.jsx +++ b/frontend/src/components/ExploreMenu/ExploreMenu.jsx @@ -194,14 +194,16 @@ const ExploreMenu = ({ category, setCategory }) => { > {/* Triple the items for seamless infinite scroll */} {[...menu_list, ...menu_list, ...menu_list].map((item, index) => ( - +
setCategory( category === item.menu_name ? "All" : item.menu_name ) } - key={`${item.menu_name}-${index}`} className="explore-menu-list-item" > { const options = [ { - icon: window.open(`https://wa.me/?text=${encodeURIComponent(shareUrl)}`)} - />, - text: "WhatsApp" + icon: , + text: "WhatsApp", + onClick: () => window.open(`https://wa.me/?text=${encodeURIComponent(shareUrl)}`) }, { - icon: window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent( - shareUrl - )}`, "_blank")} - />, - text: "Instagram" + icon: , + text: "Instagram", + onClick: () => window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, "_blank") }, { - icon: window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent( - shareUrl - )}`)} - />, - text: "Twitter" + icon: , + text: "Twitter", + onClick: () => window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`) }, { - icon: window.open(`mailto:?subject=${encodeURIComponent( - "Check out this food on Foodie!" - )}&body=${encodeURIComponent( - `I found this food item, thought you might like it: ${shareUrl}` - )}`)} - />, - text: "Mail" + icon: , + text: "Mail", + onClick: () => window.open(`mailto:?subject=${encodeURIComponent("Check out this food on Foodie!")}&body=${encodeURIComponent(`I found this food item, thought you might like it: ${shareUrl}`)}`) } ] const handleOpen = () => setOpen(true); @@ -174,7 +150,9 @@ const FoodDetail = () => { options.map((item, index) => { return (
- {item.icon} + + {item.icon} + {item.text}
) diff --git a/frontend/src/components/FoodDisplay/FoodDisplay.css b/frontend/src/components/FoodDisplay/FoodDisplay.css index 1bebab9c..cbb9ab32 100644 --- a/frontend/src/components/FoodDisplay/FoodDisplay.css +++ b/frontend/src/components/FoodDisplay/FoodDisplay.css @@ -45,7 +45,7 @@ cursor: pointer; border-radius: 4px; font-weight: 500; - margin: 0 8px + margin: 0 8px; color: #333; transition: all 0.3s ease; } diff --git a/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.css b/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.css new file mode 100644 index 00000000..e88df597 --- /dev/null +++ b/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.css @@ -0,0 +1,419 @@ +.foodie-rewards-hub { + background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 50%, #ffd23f 100%); + border-radius: 20px; + margin: 20px 0; + box-shadow: 0 10px 40px rgba(255, 107, 53, 0.3); + overflow: hidden; + color: white; + position: relative; +} + +.foodie-rewards-hub::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.1); + z-index: 1; +} + +.foodie-rewards-hub > * { + position: relative; + z-index: 2; +} + +.hub-header { + padding: 30px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} + +.hub-title { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.hub-title h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.hub-title p { + margin: 5px 0 0 0; + opacity: 0.9; + font-size: 0.95rem; +} + +.hub-tabs { + display: flex; + gap: 10px; +} + +.tab-btn { + padding: 10px 20px; + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 25px; + color: white; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(5px); +} + +.tab-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.tab-btn.active { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.6); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.hub-content { + padding: 30px; +} + +/* Overview Styles */ +.rewards-overview { + display: flex; + flex-direction: column; + gap: 25px; +} + +.points-balance-card { + display: flex; + align-items: center; + gap: 20px; + background: rgba(255, 255, 255, 0.15); + padding: 25px; + border-radius: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.points-icon { + background: rgba(255, 255, 255, 0.2); + padding: 15px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.points-info h3 { + margin: 0; + font-size: 2.5rem; + font-weight: 800; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.points-info p { + margin: 5px 0 0 0; + opacity: 0.9; + font-size: 1.1rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 15px; +} + +.stat-card { + background: rgba(255, 255, 255, 0.15); + padding: 20px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + transition: transform 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-3px); +} + +.stat-card h4 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} + +.stat-card p { + margin: 2px 0 0 0; + opacity: 0.9; + font-size: 0.9rem; +} + +.next-rewards { + background: rgba(255, 255, 255, 0.1); + padding: 20px; + border-radius: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.next-rewards h4 { + margin: 0 0 15px 0; + font-size: 1.2rem; + font-weight: 600; +} + +.goal-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.goal-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + font-size: 0.9rem; +} + +.goal-item.achievement-ready { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.8; } +} + +/* Actions Styles */ +.rewards-actions { + display: flex; + flex-direction: column; + gap: 25px; +} + +.rewards-actions h4 { + margin: 0 0 15px 0; + font-size: 1.2rem; + font-weight: 600; +} + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 15px; +} + +.action-card { + background: rgba(255, 255, 255, 0.15); + padding: 20px; + border-radius: 12px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.action-card:hover:not(.disabled) { + transform: translateY(-3px); + background: rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.action-card.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-icon { + background: rgba(255, 255, 255, 0.2); + padding: 12px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; +} + +.action-content h5 { + margin: 0 0 8px 0; + font-size: 1.1rem; + font-weight: 600; +} + +.action-content p { + margin: 0 0 10px 0; + opacity: 0.9; + font-size: 0.9rem; + line-height: 1.4; +} + +.action-points { + display: flex; + align-items: center; + gap: 5px; + font-weight: 600; + font-size: 0.9rem; +} + +.action-disabled-reason { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.7); + padding: 5px 10px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; +} + +.demo-section { + background: rgba(255, 255, 255, 0.1); + padding: 25px; + border-radius: 15px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.demo-section h4 { + margin: 0 0 10px 0; + font-size: 1.2rem; + font-weight: 600; +} + +.demo-section p { + margin: 0 0 20px 0; + opacity: 0.9; +} + +.demo-buttons { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.demo-order-btn { + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 15px 20px; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 150px; +} + +.demo-order-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); +} + +.demo-order-btn.premium { + background: rgba(255, 215, 63, 0.3); + border-color: rgba(255, 215, 63, 0.5); +} + +.demo-order-btn span { + font-size: 0.8rem; + opacity: 0.9; + font-weight: 500; +} + +.hub-footer { + padding: 20px 30px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border-top: 1px solid rgba(255, 255, 255, 0.2); + text-align: center; +} + +.hub-footer p { + margin: 0; + opacity: 0.9; + font-size: 0.95rem; +} + +/* Dark theme support */ +.foodie-rewards-hub.dark { + background: linear-gradient(135deg, #2d1810 0%, #1a1a1a 50%, #2d1810 100%); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.foodie-rewards-hub.dark::before { + background: rgba(255, 255, 255, 0.05); +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .hub-header { + padding: 20px; + } + + .hub-title { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .hub-title h2 { + font-size: 1.5rem; + } + + .hub-tabs { + width: 100%; + justify-content: center; + } + + .tab-btn { + flex: 1; + text-align: center; + } + + .hub-content { + padding: 20px; + } + + .points-balance-card { + flex-direction: column; + text-align: center; + gap: 15px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .action-grid { + grid-template-columns: 1fr; + } + + .demo-buttons { + flex-direction: column; + } + + .demo-order-btn { + width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.jsx b/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.jsx new file mode 100644 index 00000000..916afd33 --- /dev/null +++ b/frontend/src/components/FoodieRewardsHub/FoodieRewardsHub.jsx @@ -0,0 +1,253 @@ +import React, { useContext, useState } from 'react'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { StoreContext } from '../context/StoreContext'; +import { ThemeContext } from '../context/ThemeContext'; +import { Star, Trophy, Gift, Coins, Target, CheckCircle, Lock } from 'lucide-react'; +import './FoodieRewardsHub.css'; + +const FoodieRewardsHub = () => { + const { isAuthenticated, user } = useContext(StoreContext); + const { theme } = useContext(ThemeContext); + const { + userPoints, + earnPoints, + calculateOrderPoints, + updateUserStats, + getUserStats, + checkAchievements, + availableRewards + } = useContext(LoyaltyContext); + + const [activeTab, setActiveTab] = useState('overview'); + + if (!isAuthenticated) { + return null; + } + + const userStats = getUserStats(); + const currentPoints = userPoints || 0; + + // Quick actions that users can perform + const quickActions = [ + { + id: 'review', + title: 'Write a Review', + description: 'Share your experience and earn 25 points', + points: 25, + icon: Star, + condition: () => userStats.ordersCount > 0, // Can only review after ordering + action: () => { + if (userStats.ordersCount > 0) { + updateUserStats('reviews_count', userStats.reviewsCount + 1); + earnPoints(25, 'Review submitted'); + setTimeout(() => checkAchievements(), 1000); + } + } + }, + { + id: 'social_share', + title: 'Share on Social Media', + description: 'Share Foodie with friends and earn 15 points', + points: 15, + icon: Gift, + condition: () => true, // Always available + action: () => { + earnPoints(15, 'Shared on social media'); + setTimeout(() => checkAchievements(), 1000); + } + }, + { + id: 'daily_check', + title: 'Daily Check-in', + description: 'Visit daily and earn 10 points', + points: 10, + icon: CheckCircle, + condition: () => { + const lastCheckIn = localStorage.getItem('lastDailyCheckIn'); + const today = new Date().toDateString(); + return lastCheckIn !== today; + }, + action: () => { + const today = new Date().toDateString(); + localStorage.setItem('lastDailyCheckIn', today); + earnPoints(10, 'Daily check-in bonus'); + setTimeout(() => checkAchievements(), 1000); + } + } + ]; + + // Order simulation for demo purposes + const simulateOrderCompletion = (amount) => { + const points = calculateOrderPoints(amount); + updateUserStats('orders_count', userStats.ordersCount + 1); + updateUserStats('total_spent', userStats.totalSpent + amount); + earnPoints(points, `Order of $${amount} completed`); + setTimeout(() => checkAchievements(), 1000); + }; + + const renderOverview = () => ( +
+
+
+ +
+
+

{currentPoints}

+

Available Points

+
+
+ +
+
+ +
+

{userStats.ordersCount}

+

Orders Placed

+
+
+
+ +
+

{userStats.reviewsCount}

+

Reviews Written

+
+
+
+ +
+

${userStats.totalSpent}

+

Total Spent

+
+
+
+ +
+

๐ŸŽฏ Your Next Goals

+
+ {userStats.ordersCount < 5 && ( +
+ + Place {5 - userStats.ordersCount} more orders to unlock "Regular Customer" achievement +
+ )} + {userStats.totalSpent < 2000 && ( +
+ + Spend ${200 - userStats.totalSpent} more to unlock "Big Spender" achievement +
+ )} + {currentPoints >= 500 && ( +
+ + ๐ŸŽ‰ You can redeem $5 OFF! Visit the Rewards page. +
+ )} +
+
+
+ ); + + const renderActions = () => ( +
+

๐Ÿš€ Earn More Points

+
+ {quickActions.map((action) => { + const Icon = action.icon; + const isEnabled = action.condition(); + + return ( +
+
+ {isEnabled ? : } +
+
+
{action.title}
+

{action.description}

+
+ + +{action.points} points +
+
+ {!isEnabled && ( +
+ {action.id === 'review' && 'Complete an order first'} +
+ )} +
+ ); + })} +
+ +
+

๐Ÿ“ฑ Demo: Place Orders

+

Experience how you earn points with every order!

+
+ + + +
+
+
+ ); + + return ( +
+
+
+ +
+

Foodie Rewards Hub

+

Hello {user?.name}! Track your points and unlock amazing rewards

+
+
+ +
+ + +
+
+ +
+ {activeTab === 'overview' ? renderOverview() : renderActions()} +
+ +
+

๐Ÿ† Visit the Rewards page to redeem your points for discounts and free items!

+
+
+ ); +}; + +export default FoodieRewardsHub; \ No newline at end of file diff --git a/frontend/src/components/Icons/TwitterIcon.jsx b/frontend/src/components/Icons/TwitterIcon.jsx index 2d449324..0da1bc51 100644 --- a/frontend/src/components/Icons/TwitterIcon.jsx +++ b/frontend/src/components/Icons/TwitterIcon.jsx @@ -5,7 +5,7 @@ const TwitterIcon = () => {
- + ) diff --git a/frontend/src/components/LoginPopup/LoginPopup.jsx b/frontend/src/components/LoginPopup/LoginPopup.jsx index 54666271..8794ecc5 100644 --- a/frontend/src/components/LoginPopup/LoginPopup.jsx +++ b/frontend/src/components/LoginPopup/LoginPopup.jsx @@ -1,13 +1,15 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useContext } from 'react'; import './LoginPopup.css'; import { assets } from '../../assets/frontend_assets/assets'; import toast, { Toaster } from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import apiRequest from "../../lib/apiRequest"; import { EyeIcon,EyeOffIcon } from "lucide-react"; +import { StoreContext } from '../context/StoreContext'; const LoginPopup = ({ setShowLogin }) => { + const { login } = useContext(StoreContext); const [currState, setCurrState] = useState("Sign Up"); const [forgotFlow, setForgotFlow] = useState(false); const [stage, setStage] = useState(1); @@ -147,35 +149,47 @@ const LoginPopup = ({ setShowLogin }) => { const password = formData.get("password"); const confirmPassword = formData.get("confirmPassword"); - if (!email || !password || (currState === "Sign Up" && !name)) { - return toast.error("Please fill all fields"); - } + // Basic validation + if (!email || !password) { + return toast.error("Email and password are required"); + } - if (currState === "Sign Up" && password !== confirmPassword) { - return toast.error("Passwords do not match"); + if (currState === "Sign Up") { + if (!name) { + return toast.error("Name is required"); + } + if (password !== confirmPassword) { + return toast.error("Passwords do not match"); + } + if (password.length < 6) { + return toast.error("Password must be at least 6 characters"); + } } - const endpoint = - currState === "Sign Up" ? "/api/auth/register" : "/api/auth/login"; + const endpoint = currState === "Sign Up" ? "/api/auth/register" : "/api/auth/login"; + const userData = currState === "Sign Up" + ? { name, email, password } + : { email, password }; try { - const { data } = await apiRequest.post(endpoint, { name, email, password }); + const { data } = await apiRequest.post(endpoint, userData); - toast.success(`${currState} successful!`); + toast.success(data.message || `${currState} successful!`); - // Store user info and auth token locally - localStorage.setItem("user", JSON.stringify(data.user)); - if (data.token) { - localStorage.setItem("authToken", data.token); - } + // Use the context login function + login(data.user, data.token); - setShowLogin(false); - window.location.reload(); + setShowLogin(false); + + // Reset form + e.target.reset(); + setPassword(''); + setSignUpConfirmPassword(''); + } catch (err) { - const message = - err.response?.data?.message || `${currState} failed. Please try again.`; + const message = err.response?.data?.message || `${currState} failed. Please try again.`; toast.error(message); - console.error(err); + console.error(`${currState} error:`, err); } }; diff --git a/frontend/src/components/LoyaltyRewards/LoyaltyRewards.css b/frontend/src/components/LoyaltyRewards/LoyaltyRewards.css new file mode 100644 index 00000000..1b53599e --- /dev/null +++ b/frontend/src/components/LoyaltyRewards/LoyaltyRewards.css @@ -0,0 +1,276 @@ +/* Loyalty Rewards Component Styles */ +.loyalty-rewards { + margin: 1rem 0; + padding: 1rem; + border-radius: 12px; + transition: all 0.3s ease; +} + +.loyalty-rewards.light { + background: #f8f9fa; + border: 1px solid #e9ecef; +} + +.loyalty-rewards.dark { + background: #2d2d2d; + border: 1px solid #404040; +} + +/* Points Preview */ +.points-preview { + margin-bottom: 1rem; +} + +.points-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 8px; + background: linear-gradient(135deg, #ff6b6b, #ee5a24); + color: white; +} + +.points-icon { + color: #ffd700; +} + +.points-text { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.current-points { + font-weight: 600; + font-size: 0.9rem; +} + +.earning-points { + font-size: 0.8rem; + opacity: 0.9; +} + +/* Available Rewards */ +.available-rewards { + border-radius: 8px; + overflow: hidden; +} + +.loyalty-rewards.light .available-rewards { + border: 1px solid #dee2e6; +} + +.loyalty-rewards.dark .available-rewards { + border: 1px solid #495057; +} + +.rewards-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.loyalty-rewards.light .rewards-header { + background: white; +} + +.loyalty-rewards.dark .rewards-header { + background: #333; +} + +.rewards-header:hover { + background: rgba(255, 107, 107, 0.1); +} + +.rewards-header .gift-icon { + color: #ff6b6b; +} + +.toggle-rewards { + background: #ff6b6b; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.3s ease; +} + +.toggle-rewards:hover { + background: #ee5a24; +} + +/* Rewards List */ +.rewards-list { + padding: 0.5rem; +} + +.loyalty-rewards.light .rewards-list { + background: #f8f9fa; +} + +.loyalty-rewards.dark .rewards-list { + background: #1a1a1a; +} + +.reward-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + border-radius: 8px; + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.loyalty-rewards.light .reward-item { + background: white; +} + +.loyalty-rewards.dark .reward-item { + background: #2d2d2d; +} + +.reward-item:hover:not(.disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.reward-item.selected { + border-color: #4ecdc4; + background: rgba(78, 205, 196, 0.1); +} + +.reward-item.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reward-item .reward-icon { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #ff6b6b, #ee5a24); + color: white; + flex-shrink: 0; +} + +.reward-details { + flex: 1; +} + +.reward-details h4 { + margin: 0 0 0.25rem 0; + font-size: 0.9rem; + font-weight: 600; +} + +.reward-details p { + margin: 0; + font-size: 0.8rem; + opacity: 0.8; +} + +.reason-text { + display: block; + font-size: 0.75rem; + color: #dc3545; + font-style: italic; + margin-top: 0.25rem; +} + +.apply-btn, +.remove-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.3s ease; + min-width: 70px; +} + +.apply-btn { + background: #4ecdc4; + color: white; +} + +.apply-btn:hover:not(.disabled) { + background: #44a08d; + transform: translateY(-1px); +} + +.apply-btn.disabled { + background: #ccc; + cursor: not-allowed; +} + +.remove-btn { + background: #dc3545; + color: white; +} + +.remove-btn:hover { + background: #c82333; + transform: translateY(-1px); +} + +/* Applied Reward */ +.applied-reward { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(78, 205, 196, 0.1); + border: 1px solid #4ecdc4; + color: #2c7a7b; + font-weight: 500; + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.applied-icon { + color: #4ecdc4; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .loyalty-rewards { + margin: 0.5rem 0; + padding: 0.75rem; + } + + .points-info { + flex-direction: column; + text-align: center; + gap: 0.5rem; + } + + .rewards-header { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } + + .reward-item { + flex-direction: column; + text-align: center; + gap: 0.75rem; + } + + .reward-details { + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/components/LoyaltyRewards/LoyaltyRewards.jsx b/frontend/src/components/LoyaltyRewards/LoyaltyRewards.jsx new file mode 100644 index 00000000..0ff01be2 --- /dev/null +++ b/frontend/src/components/LoyaltyRewards/LoyaltyRewards.jsx @@ -0,0 +1,174 @@ +import React, { useContext, useState } from 'react'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { StoreContext } from '../context/StoreContext'; +import { ThemeContext } from '../context/ThemeContext'; +import { Gift, Zap, Percent, Truck, Star } from 'lucide-react'; +import './LoyaltyRewards.css'; + +const LoyaltyRewards = ({ orderAmount, onApplyReward }) => { + const { theme } = useContext(ThemeContext); + const { isAuthenticated } = useContext(StoreContext); + const { + userPoints, + calculateOrderPoints, + getUnusedRedemptions, + markRedemptionAsUsed, + REWARDS_CATALOG + } = useContext(LoyaltyContext); + + const [selectedReward, setSelectedReward] = useState(null); + const [showRewards, setShowRewards] = useState(false); + + if (!isAuthenticated) { + return null; + } + + const potentialPoints = calculateOrderPoints(orderAmount); + const unusedRedemptions = getUnusedRedemptions(); + + const handleApplyReward = (redemption) => { + const reward = REWARDS_CATALOG.find(r => r.id === redemption.rewardId); + if (!reward) return; + + let discountAmount = 0; + let freeDelivery = false; + + switch (reward.type) { + case 'discount': + discountAmount = Math.min( + (orderAmount * reward.value) / 100, + reward.maxDiscount || Infinity + ); + break; + case 'free_delivery': + freeDelivery = true; + break; + case 'cashback': + if (orderAmount >= (reward.minOrder || 0)) { + discountAmount = reward.value; + } + break; + default: + break; + } + + if (discountAmount > 0 || freeDelivery) { + setSelectedReward(redemption); + markRedemptionAsUsed(redemption.id); + onApplyReward({ + redemptionId: redemption.id, + rewardName: reward.name, + discountAmount, + freeDelivery, + type: reward.type + }); + } + }; + + const removeAppliedReward = () => { + setSelectedReward(null); + onApplyReward(null); + }; + + const getRewardIcon = (type) => { + switch (type) { + case 'discount': + return ; + case 'free_delivery': + return ; + case 'cashback': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Points Preview */} +
+
+ +
+ You have {userPoints} points + +{potentialPoints} points with this order +
+
+
+ + {/* Available Rewards */} + {unusedRedemptions.length > 0 && ( +
+
setShowRewards(!showRewards)}> + + You have {unusedRedemptions.length} reward(s) to use + +
+ + {showRewards && ( +
+ {unusedRedemptions.map(redemption => { + const reward = REWARDS_CATALOG.find(r => r.id === redemption.rewardId); + if (!reward) return null; + + const isSelected = selectedReward?.id === redemption.id; + let canApply = true; + let reasonText = ''; + + // Check if reward can be applied + if (reward.type === 'cashback' && orderAmount < (reward.minOrder || 0)) { + canApply = false; + reasonText = `Minimum order $${reward.minOrder} required`; + } + + return ( +
+
+ {getRewardIcon(reward.type)} +
+
+

{reward.name}

+

{reward.description}

+ {!canApply && {reasonText}} +
+ {isSelected ? ( + + ) : ( + + )} +
+ ); + })} +
+ )} +
+ )} + + {/* Applied Reward Display */} + {selectedReward && ( +
+ + Reward applied: {REWARDS_CATALOG.find(r => r.id === selectedReward.rewardId)?.name} +
+ )} +
+ ); +}; + +export default LoyaltyRewards; \ No newline at end of file diff --git a/frontend/src/components/Navbar/Navbar.jsx b/frontend/src/components/Navbar/Navbar.jsx index 95797c98..5717d8a9 100644 --- a/frontend/src/components/Navbar/Navbar.jsx +++ b/frontend/src/components/Navbar/Navbar.jsx @@ -19,23 +19,25 @@ import { Users, Info, CircleDollarSign, + Gift, } from "lucide-react"; const Navbar = ({ setShowLogin }) => { const [menu, setMenu] = useState("home"); - const { cartItems, wishlistItems, toggleWishlist, getTotalCartAmount } = - useContext(StoreContext); + const { + cartItems, + wishlistItems, + toggleWishlist, + getTotalCartAmount, + user, + isAuthenticated, + logout + } = useContext(StoreContext); const { theme, toggleTheme } = useContext(ThemeContext); - const [user, setUser] = useState(null); const navigate = useNavigate(); const location = useLocation(); - useEffect(() => { - const storedUser = JSON.parse(localStorage.getItem("user")); - setUser(storedUser); - }, []); - const handleNavMenuClick = (menuName, id) => { setMenu(menuName); if (location.pathname !== "/") { @@ -47,10 +49,8 @@ const Navbar = ({ setShowLogin }) => { }; const handleLogout = () => { - localStorage.removeItem("user"); - localStorage.removeItem("token"); - setUser(null); - window.location.reload(); + logout(); + navigate("/"); }; // to trigger the dark theme on scroll bar @@ -117,6 +117,15 @@ const Navbar = ({ setShowLogin }) => { + setMenu("rewards")} + className={`nav-item ${menu === "rewards" ? "active" : ""}`} + > + + Rewards + + {
{/* User / Auth */} - {user ? ( + {isAuthenticated && user ? (
{user.name?.charAt(0).toUpperCase()} diff --git a/frontend/src/components/PointsIndicator/PointsIndicator.css b/frontend/src/components/PointsIndicator/PointsIndicator.css new file mode 100644 index 00000000..7c754121 --- /dev/null +++ b/frontend/src/components/PointsIndicator/PointsIndicator.css @@ -0,0 +1,44 @@ +/* Points Indicator Styles */ +.points-indicator { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + background: linear-gradient(135deg, #ff6b6b, #ee5a24); + color: white; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3); + transition: all 0.3s ease; + cursor: pointer; +} + +.points-indicator:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4); +} + +.points-icon { + width: 16px; + height: 16px; + color: #ffd700; +} + +.points-count { + min-width: 20px; + text-align: center; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .points-indicator { + padding: 0.4rem 0.6rem; + font-size: 0.8rem; + } + + .points-icon { + width: 14px; + height: 14px; + } +} \ No newline at end of file diff --git a/frontend/src/components/PointsIndicator/PointsIndicator.jsx b/frontend/src/components/PointsIndicator/PointsIndicator.jsx new file mode 100644 index 00000000..47be3174 --- /dev/null +++ b/frontend/src/components/PointsIndicator/PointsIndicator.jsx @@ -0,0 +1,23 @@ +import React, { useContext } from 'react'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { StoreContext } from '../context/StoreContext'; +import { Zap } from 'lucide-react'; +import './PointsIndicator.css'; + +const PointsIndicator = () => { + const { isAuthenticated } = useContext(StoreContext); + const { userPoints } = useContext(LoyaltyContext); + + if (!isAuthenticated) { + return null; + } + + return ( +
+ + {userPoints} +
+ ); +}; + +export default PointsIndicator; \ No newline at end of file diff --git a/frontend/src/components/Referrals/ReferralProgram.jsx b/frontend/src/components/Referrals/ReferralProgram.jsx index 2883ebfd..de7fb878 100644 --- a/frontend/src/components/Referrals/ReferralProgram.jsx +++ b/frontend/src/components/Referrals/ReferralProgram.jsx @@ -74,7 +74,7 @@ const ReferralProgram = () => {

Referrals Pending

-

โ‚น500

+

$50

Rewards Earned

diff --git a/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.css b/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.css new file mode 100644 index 00000000..382b7f0d --- /dev/null +++ b/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.css @@ -0,0 +1,374 @@ +/* ===== Overlay ===== */ +.rewards-promo-overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + z-index: 1001; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: stretch; + justify-content: stretch; + backdrop-filter: blur(6px); + animation: fadeIn 0.4s ease forwards; + padding: 0; + box-sizing: border-box; + overflow: hidden; +} + +/* ===== Popup Container ===== */ +.rewards-promo-popup { + position: relative; + width: 100vw; + height: 100vh; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(18px) saturate(180%); + -webkit-backdrop-filter: blur(18px) saturate(180%); + border: none; + border-radius: 0; + padding: 40px; + box-shadow: none; + animation: slideUp 0.5s ease forwards; + transition: all 0.3s ease; + color: white; + font-family: "Inter", sans-serif; + overflow-y: auto; + margin: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.rewards-promo-popup.dark { + background: rgba(30, 34, 46, 0.95); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #e2e8f0; +} + +/* ===== Close Button ===== */ +.close-button { + position: absolute; + top: 14px; + right: 14px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(229, 231, 235, 0.5); + color: #64748b; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 32px; + height: 32px; +} + +.close-button:hover { + background: rgba(255, 255, 255, 1); + border-color: rgba(229, 231, 235, 0.8); + color: #374151; + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.dark .close-button { + background: rgba(45, 55, 72, 0.9); + border: 1px solid rgba(75, 85, 99, 0.5); + color: #a0aec0; +} + +.dark .close-button:hover { + background: rgba(45, 55, 72, 1); + border-color: rgba(75, 85, 99, 0.8); + color: #e2e8f0; + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* ===== Header ===== */ +.promo-header { + text-align: center; + margin-bottom: 22px; +} + +.promo-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 58px; + height: 58px; + background: linear-gradient(135deg, #ff6347, #ff7a59); + border-radius: 50%; + margin-bottom: 14px; + box-shadow: 0 6px 16px rgba(255, 99, 71, 0.35); +} + +.promo-icon svg { + color: #fff; + stroke: #fff; + width: 32px; + height: 32px; +} + +/* Dark theme: use a golden/yellow stroke for better contrast */ +.dark .promo-icon { + background: linear-gradient(135deg, #ffb347, #ffcc33); + box-shadow: 0 6px 16px rgba(255, 204, 51, 0.3); +} + +.dark .promo-icon svg { + color: #1a202c; /* dark gray/near black */ + stroke: #1a202c; +} + +.promo-header h2 { + font-size: 1.35rem; + font-weight: 700; + margin: 0; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* ===== Points Display ===== */ +.points-display { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 20px; + padding: 14px; + background: linear-gradient(135deg, rgba(255, 99, 71, 0.08), rgba(255, 122, 89, 0.08)); + border-radius: 12px; + border: 1px solid rgba(255, 99, 71, 0.2); +} + +.dark .points-display { + background: linear-gradient(135deg, rgba(255, 99, 71, 0.12), rgba(255, 122, 89, 0.12)); + border-color: rgba(255, 99, 71, 0.25); +} + +.points-icon { + color: #ffffff; /* gold for visibility */ + stroke: #ffffff; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Dark mode tweak */ +.dark .points-icon { + color: #ffeb3b; /* brighter yellow */ + stroke: #ffeb3b; +} + + +.points-count { + font-size: 1.7rem; + font-weight: 800; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); +} + +.points-label, +.points-text { + font-size: 0.9rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); +} + +.dark .points-label, +.dark .points-text { + color: rgba(255, 255, 255, 0.8); +} + +/* ===== Rewards Preview ===== */ +.rewards-preview { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 18px; +} + +.reward-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(229, 231, 235, 0.8); + border-radius: 10px; + font-size: 0.92rem; + transition: all 0.2s ease; +} + +.reward-item:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); +} + +.dark .reward-item { + background: rgba(45, 55, 72, 0.6); + border: 1px solid rgba(75, 85, 99, 0.6); +} + +.reward-item svg { + color: white; +} + +.reward-item span:nth-child(2) { + flex: 1; + font-weight: 500; + color: white; +} + +.cost { + font-size: 0.8rem; + font-weight: 600; + color: white; + background: rgba(255, 255, 255, 0.2); + padding: 4px 8px; + border-radius: 6px; +} + +/* ===== Description ===== */ +.promo-description { + text-align: center; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.9); + line-height: 1.5; + margin: 0 0 20px; +} + +.dark .promo-description { + color: rgba(255, 255, 255, 0.8); +} + +/* ===== Actions ===== */ +.promo-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.explore-btn { + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #ff6347, #ff7a59); + color: white; + border: none; + padding: 12px 20px; + border-radius: 12px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 14px rgba(255, 99, 71, 0.3); +} + +.explore-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(255, 99, 71, 0.4); +} + +.maybe-later-btn { + background: none; + border: 1px solid #d1d5db; + color: #64748b; + padding: 12px 20px; + border-radius: 12px; + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.maybe-later-btn:hover { + background: rgba(100, 116, 139, 0.05); + border-color: #9ca3af; + color: #374151; +} + +.dark .maybe-later-btn { + border-color: #4b5563; + color: #a0aec0; +} + +.dark .maybe-later-btn:hover { + background: rgba(156, 163, 175, 0.08); + border-color: #6b7280; + color: #e2e8f0; +} + +/* ===== Animations ===== */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(30px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* ===== Responsive ===== */ +@media (max-width: 480px) { + .rewards-promo-overlay { + padding: 0; + } + + .rewards-promo-popup { + width: 100vw; + height: 100vh; + padding: 30px 20px; + border-radius: 0; + } + + .promo-header h2 { + font-size: 1.2rem; + } + + .points-count { + font-size: 1.5rem; + } + + .promo-actions { + flex-direction: column; + } + + .explore-btn, + .maybe-later-btn { + width: 100%; + justify-content: center; + } + + .close-button { + top: 20px; + right: 20px; + width: 32px; + height: 32px; + padding: 8px; + } +} + +@media (max-height: 600px) { + .rewards-promo-overlay { + padding: 0; + } + + .rewards-promo-popup { + height: 100vh; + padding: 20px; + } +} diff --git a/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.jsx b/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.jsx new file mode 100644 index 00000000..d874750d --- /dev/null +++ b/frontend/src/components/RewardsPromoPopup/RewardsPromoPopup.jsx @@ -0,0 +1,138 @@ +import React, { useContext, useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { StoreContext } from '../context/StoreContext'; +import { LoyaltyContext } from '../context/LoyaltyContext'; +import { ThemeContext } from '../context/ThemeContext'; +import { Gift, Star, X, Zap, Crown } from 'lucide-react'; +import './RewardsPromoPopup.css'; + +const RewardsPromoPopup = () => { + const { isAuthenticated } = useContext(StoreContext); + const { userPoints } = useContext(LoyaltyContext); + const { theme } = useContext(ThemeContext); + const [showPopup, setShowPopup] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + // Only show popup for authenticated users + if (!isAuthenticated) return; + + // Show popup every time user visits the website + const timer = setTimeout(() => { + setShowPopup(true); + }, 3000); // Show after 3 seconds + + return () => clearTimeout(timer); + }, [isAuthenticated]); + + // Effect to handle navbar visibility + useEffect(() => { + const navbar = document.querySelector('.navbar'); + const mobileNav = document.querySelector('.navbar-menu-mobile'); + + if (showPopup) { + // Hide navbar when popup shows + if (navbar) { + navbar.style.transform = 'translateY(-100%)'; + navbar.style.transition = 'transform 0.3s ease'; + } + if (mobileNav) { + mobileNav.style.transform = 'translateY(100%)'; + mobileNav.style.transition = 'transform 0.3s ease'; + } + } else { + // Show navbar when popup closes + if (navbar) { + navbar.style.transform = 'translateY(0)'; + } + if (mobileNav) { + mobileNav.style.transform = 'translateY(0)'; + } + } + + // Cleanup function to restore navbar when component unmounts + return () => { + if (navbar) { + navbar.style.transform = 'translateY(0)'; + } + if (mobileNav) { + mobileNav.style.transform = 'translateY(0)'; + } + }; + }, [showPopup]); + + const handleClose = () => { + setShowPopup(false); + // No longer storing last shown date - popup will show every visit + }; + + const handleExploreRewards = () => { + setShowPopup(false); + // No longer storing last shown date - popup will show every visit + navigate('/rewards'); + }; + + if (!showPopup || !isAuthenticated) { + return null; + } + + return ( +
+
+ + +
+
+ +
+

๐ŸŽ‰ Discover Your Rewards!

+
+ +
+
+ + You have + {userPoints || 0} + points +
+ +
+
+ + 10% Off Orders + 100 pts +
+
+ + Free Delivery + 50 pts +
+
+ + Cashback Offers + 300 pts +
+
+ +

+ Redeem points for discounts, free delivery, and exclusive rewards! +

+
+ +
+ + +
+
+
+ ); +}; + +export default RewardsPromoPopup; \ No newline at end of file diff --git a/frontend/src/components/context/LoyaltyContext.jsx b/frontend/src/components/context/LoyaltyContext.jsx new file mode 100644 index 00000000..10c3384c --- /dev/null +++ b/frontend/src/components/context/LoyaltyContext.jsx @@ -0,0 +1,409 @@ +import React, { createContext, useState, useEffect, useContext } from 'react'; +import { StoreContext } from './StoreContext'; +import toast from 'react-hot-toast'; + +export const LoyaltyContext = createContext(); + +const LoyaltyContextProvider = ({ children }) => { + const { user, isAuthenticated } = useContext(StoreContext); + const [userPoints, setUserPoints] = useState(0); + const [achievements, setAchievements] = useState([]); + const [rewardHistory, setRewardHistory] = useState([]); + const [loading, setLoading] = useState(false); + + // Points configuration (USD currency) + const POINTS_CONFIG = { + ORDER_POINTS: 1, // Points per dollar spent (1 point per $1) + SIGNUP_BONUS: 100, + FIRST_ORDER_BONUS: 50, + REVIEW_POINTS: 25, + REFERRAL_POINTS: 200, + }; + + // Rewards catalog + const REWARDS_CATALOG = [ + { + id: 'discount_10', + name: '10% Off Next Order', + description: 'Get 10% discount on your next order', + pointsCost: 100, + type: 'discount', + value: 10, + maxDiscount: 15, // $15 max discount + }, + { + id: 'discount_15', + name: '15% Off Next Order', + description: 'Get 15% discount on your next order', + pointsCost: 200, + type: 'discount', + value: 15, + maxDiscount: 20, // $20 max discount + }, + { + id: 'free_delivery', + name: 'Free Delivery', + description: 'Free delivery on your next order', + pointsCost: 50, + type: 'free_delivery', + value: 0, + }, + { + id: 'discount_25', + name: '25% Off Next Order', + description: 'Get 25% discount on your next order', + pointsCost: 400, + type: 'discount', + value: 25, + maxDiscount: 30, // $30 max discount + }, + { + id: 'cashback_50', + name: '$5 Cashback', + description: 'Get $5 cashback on orders above $50', + pointsCost: 300, + type: 'cashback', + value: 5, + minOrder: 50, + }, + ]; + + // Achievement definitions + const ACHIEVEMENTS = [ + { + id: 'first_order', + name: 'First Bite', + description: 'Complete your first order', + icon: '๐ŸŽ‰', + points: 50, + condition: { type: 'orders_count', value: 1 }, + }, + { + id: 'fifth_order', + name: 'Regular Customer', + description: 'Complete 5 orders', + icon: 'โญ', + points: 100, + condition: { type: 'orders_count', value: 5 }, + }, + { + id: 'tenth_order', + name: 'Food Explorer', + description: 'Complete 10 orders', + icon: '๐Ÿ†', + points: 200, + condition: { type: 'orders_count', value: 10 }, + }, + { + id: 'big_spender', + name: 'Big Spender', + description: 'Spend $500 in total', + icon: '๐Ÿ’Ž', + points: 300, + condition: { type: 'total_spent', value: 500 }, + }, + { + id: 'review_master', + name: 'Review Master', + description: 'Write 10 reviews', + icon: '๐Ÿ“', + points: 150, + condition: { type: 'reviews_count', value: 10 }, + }, + { + id: 'loyal_customer', + name: 'Loyal Customer', + description: 'Order for 30 consecutive days', + icon: '๐Ÿ‘‘', + points: 500, + condition: { type: 'consecutive_days', value: 30 }, + }, + ]; + + // Load user loyalty data when user logs in + useEffect(() => { + if (isAuthenticated && user) { + loadUserLoyaltyData(); + } else { + resetLoyaltyData(); + } + }, [isAuthenticated, user]); + + const loadUserLoyaltyData = () => { + setLoading(true); + try { + // Use real user data from backend if available + if (user.loyaltyPoints !== undefined) { + setUserPoints(user.loyaltyPoints); + setAchievements(user.achievements || []); + setRewardHistory(user.rewardHistory || []); + } else { + // Fallback to localStorage for existing users + const savedPoints = localStorage.getItem(`loyalty_points_${user._id}`); + const savedAchievements = localStorage.getItem(`achievements_${user._id}`); + const savedHistory = localStorage.getItem(`reward_history_${user._id}`); + + setUserPoints(savedPoints ? parseInt(savedPoints) : 500); // Give 500 points to existing users + setAchievements(savedAchievements ? JSON.parse(savedAchievements) : []); + setRewardHistory(savedHistory ? JSON.parse(savedHistory) : []); + } + } catch (error) { + console.error('Error loading loyalty data:', error); + // Default to 500 points for any errors + setUserPoints(500); + setAchievements([]); + setRewardHistory([]); + } + setLoading(false); + }; + + const resetLoyaltyData = () => { + setUserPoints(0); + setAchievements([]); + setRewardHistory([]); + }; + + // Save loyalty data to localStorage + const saveLoyaltyData = (points, userAchievements, history) => { + if (!user) return; + + localStorage.setItem(`loyalty_points_${user._id}`, points.toString()); + localStorage.setItem(`achievements_${user._id}`, JSON.stringify(userAchievements)); + localStorage.setItem(`reward_history_${user._id}`, JSON.stringify(history)); + }; + + // Validation function for point earning + const validatePointEarning = (reason, validationData) => { + const userStats = getUserStats(); + + switch (reason) { + case 'Review submitted': + // Can only earn review points if user has placed orders + if (userStats.ordersCount === 0) { + return { isValid: false, message: 'Complete an order first before writing reviews' }; + } + // Limit reviews per day + const reviewsToday = rewardHistory.filter(entry => + entry.reason === 'Review submitted' && + new Date(entry.timestamp).toDateString() === new Date().toDateString() + ).length; + if (reviewsToday >= 3) { + return { isValid: false, message: 'Maximum 3 reviews per day allowed' }; + } + break; + + case 'Daily check-in bonus': + // Check if already checked in today + const lastCheckIn = localStorage.getItem('lastDailyCheckIn'); + const today = new Date().toDateString(); + if (lastCheckIn === today) { + return { isValid: false, message: 'Already checked in today' }; + } + break; + + case 'Shared on social media': + // Limit social sharing points per day + const sharesTimeRange = new Date(); + sharesTimeRange.setHours(sharesTimeRange.getHours() - 24); + const recentShares = rewardHistory.filter(entry => + entry.reason === 'Shared on social media' && + new Date(entry.timestamp) > sharesTimeRange + ).length; + if (recentShares >= 2) { + return { isValid: false, message: 'Maximum 2 social shares per day allowed' }; + } + break; + + default: + // For order completion and other valid reasons + if (reason.includes('Order of $') && validationData) { + // Validate order data if provided + return { isValid: true, message: 'Valid order completion' }; + } + break; + } + + return { isValid: true, message: 'Valid action' }; + }; + + // Enhanced earn points function with validation + const earnPoints = (points, reason = 'Order completed', validationData = null) => { + if (!isAuthenticated) { + toast.error('Please login to earn points'); + return false; + } + + // Validate the point earning action + const validation = validatePointEarning(reason, validationData); + if (!validation.isValid) { + toast.error(validation.message); + return false; + } + + const newPoints = userPoints + points; + setUserPoints(newPoints); + + toast.success(`๐ŸŽ‰ You earned ${points} points! ${reason}`); + + saveLoyaltyData(newPoints, achievements, rewardHistory); + + // Check for new achievements + checkAchievements(); + + return true; + }; + + // Calculate points from order amount + const calculateOrderPoints = (orderAmount) => { + return Math.floor(orderAmount / POINTS_CONFIG.ORDER_POINTS); + }; + + // Redeem reward function + const redeemReward = (rewardId) => { + const reward = REWARDS_CATALOG.find(r => r.id === rewardId); + if (!reward) { + toast.error('Reward not found'); + return false; + } + + if (userPoints < reward.pointsCost) { + toast.error('Insufficient points'); + return false; + } + + const newPoints = userPoints - reward.pointsCost; + const redemption = { + id: Date.now().toString(), + rewardId: reward.id, + rewardName: reward.name, + pointsCost: reward.pointsCost, + redeemedAt: new Date().toISOString(), + used: false, + }; + + const newHistory = [redemption, ...rewardHistory]; + + setUserPoints(newPoints); + setRewardHistory(newHistory); + + toast.success(`๐ŸŽ ${reward.name} redeemed successfully!`); + + saveLoyaltyData(newPoints, achievements, newHistory); + + return redemption; + }; + + // Check and unlock achievements + const checkAchievements = () => { + // This would typically get user stats from API + // For now, we'll use localStorage data + const userStats = getUserStats(); + + ACHIEVEMENTS.forEach(achievement => { + const alreadyUnlocked = achievements.some(a => a.id === achievement.id); + if (alreadyUnlocked) return; + + let conditionMet = false; + switch (achievement.condition.type) { + case 'orders_count': + conditionMet = userStats.ordersCount >= achievement.condition.value; + break; + case 'total_spent': + conditionMet = userStats.totalSpent >= achievement.condition.value; + break; + case 'reviews_count': + conditionMet = userStats.reviewsCount >= achievement.condition.value; + break; + default: + break; + } + + if (conditionMet) { + unlockAchievement(achievement); + } + }); + }; + + // Unlock achievement + const unlockAchievement = (achievement) => { + const newAchievement = { + ...achievement, + unlockedAt: new Date().toISOString(), + }; + + const newAchievements = [...achievements, newAchievement]; + setAchievements(newAchievements); + + // Award achievement points + const newPoints = userPoints + achievement.points; + setUserPoints(newPoints); + + toast.success(`๐Ÿ† Achievement Unlocked: ${achievement.name}! +${achievement.points} points`); + + saveLoyaltyData(newPoints, newAchievements, rewardHistory); + }; + + // Get user stats (mock data for now) + const getUserStats = () => { + // In real app, this would come from API + return { + ordersCount: parseInt(localStorage.getItem(`orders_count_${user?._id}`) || '0'), + totalSpent: parseInt(localStorage.getItem(`total_spent_${user?._id}`) || '0'), + reviewsCount: parseInt(localStorage.getItem(`reviews_count_${user?._id}`) || '0'), + }; + }; + + // Update user stats (helper function) + const updateUserStats = (statType, value) => { + if (!user) return; + localStorage.setItem(`${statType}_${user._id}`, value.toString()); + }; + + // Get available rewards + const getAvailableRewards = () => { + return REWARDS_CATALOG.filter(reward => userPoints >= reward.pointsCost); + }; + + // Get unused redemptions + const getUnusedRedemptions = () => { + return rewardHistory.filter(redemption => !redemption.used); + }; + + // Mark redemption as used + const markRedemptionAsUsed = (redemptionId) => { + const newHistory = rewardHistory.map(redemption => + redemption.id === redemptionId + ? { ...redemption, used: true, usedAt: new Date().toISOString() } + : redemption + ); + setRewardHistory(newHistory); + saveLoyaltyData(userPoints, achievements, newHistory); + }; + + const contextValue = { + userPoints, + achievements, + rewardHistory, + loading, + REWARDS_CATALOG, + ACHIEVEMENTS, + POINTS_CONFIG, + earnPoints, + calculateOrderPoints, + redeemReward, + checkAchievements, + getAvailableRewards, + getUnusedRedemptions, + markRedemptionAsUsed, + updateUserStats, + getUserStats, + }; + + return ( + + {children} + + ); +}; + +export default LoyaltyContextProvider; \ No newline at end of file diff --git a/frontend/src/components/context/StoreContext.jsx b/frontend/src/components/context/StoreContext.jsx index 82991df7..d4d88115 100644 --- a/frontend/src/components/context/StoreContext.jsx +++ b/frontend/src/components/context/StoreContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useState } from "react"; +import { createContext, useState, useEffect } from "react"; import { toast } from "react-toastify"; import { food_list } from "../../assets/frontend_assets/assets"; @@ -7,6 +7,43 @@ export const StoreContext = createContext(); const StoreContextProvider = ({ children }) => { const [cartItems, setCartItems] = useState({}); const [wishlistItems, setWishlistItems] = useState({}); + const [user, setUser] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + // Check authentication status on app load + useEffect(() => { + const storedUser = localStorage.getItem("user"); + const storedToken = localStorage.getItem("authToken"); + + if (storedUser && storedToken) { + try { + setUser(JSON.parse(storedUser)); + setIsAuthenticated(true); + } catch (error) { + console.error("Error parsing stored user data:", error); + localStorage.removeItem("user"); + localStorage.removeItem("authToken"); + } + } + }, []); + + // Login function + const login = (userData, token) => { + setUser(userData); + setIsAuthenticated(true); + localStorage.setItem("user", JSON.stringify(userData)); + localStorage.setItem("authToken", token); + }; + + // Logout function + const logout = () => { + setUser(null); + setIsAuthenticated(false); + localStorage.removeItem("user"); + localStorage.removeItem("authToken"); + setCartItems({}); + setWishlistItems({}); + }; /** Add an item to the cart or increment quantity (with max limit) */ const addToCart = (itemId) => { @@ -87,6 +124,10 @@ const addToCart = (itemId) => { toggleWishlist, isInWishlist, getWishlistCount, + user, + isAuthenticated, + login, + logout, }; return ( diff --git a/frontend/src/pages/Cart/Cart.jsx b/frontend/src/pages/Cart/Cart.jsx index b6b7b541..5ba0e698 100644 --- a/frontend/src/pages/Cart/Cart.jsx +++ b/frontend/src/pages/Cart/Cart.jsx @@ -2,6 +2,7 @@ import "./Cart.css"; import React, { useContext, useState } from "react"; import { StoreContext } from "../../components/context/StoreContext"; import AddressSection from "../../components/AddressSection/AddressSection.jsx" +import CartPointsPreview from "../../components/CartPointsPreview/CartPointsPreview.jsx"; import { useNavigate, Link } from "react-router-dom"; const Cart = () => { @@ -146,6 +147,7 @@ const Cart = () => { })}
+

Cart Totals

diff --git a/frontend/src/pages/Home/Home.jsx b/frontend/src/pages/Home/Home.jsx index 72d1faf8..dbb095b1 100644 --- a/frontend/src/pages/Home/Home.jsx +++ b/frontend/src/pages/Home/Home.jsx @@ -4,6 +4,7 @@ import "./Home.css"; import Header from "../../components/Header/Header"; import ExploreMenu from "../../components/ExploreMenu/ExploreMenu"; import FoodDisplay from "../../components/FoodDisplay/FoodDisplay"; +import RewardsPromoPopup from "../../components/RewardsPromoPopup/RewardsPromoPopup"; import { use } from "react"; const Home = () => { @@ -44,6 +45,7 @@ const Home = () => { return (
+
diff --git a/frontend/src/pages/Rewards/Rewards.css b/frontend/src/pages/Rewards/Rewards.css new file mode 100644 index 00000000..2a23e1eb --- /dev/null +++ b/frontend/src/pages/Rewards/Rewards.css @@ -0,0 +1,627 @@ +/* ===================== */ +/* Rewards Page Styles */ +/* ===================== */ + +.rewards-page { + min-height: 100vh; + padding: 2rem 0; + transition: all 0.3s ease; + font-family: "Inter", sans-serif; +} + +/* Light & Dark Backgrounds */ +.rewards-page.light { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + color: #222; +} + +.rewards-page.dark { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + color: #f5f5f5; +} + +.rewards-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +/* ===================== */ +/* Header Section */ +/* ===================== */ + +.rewards-header { + margin-bottom: 2rem; +} + +.points-display { + display: flex; + align-items: center; + gap: 2rem; + margin-bottom: 2rem; + padding: 2rem; + border-radius: 16px; + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); + color: white; + box-shadow: 0 8px 32px rgba(255, 107, 107, 0.3); +} + +.points-circle { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 120px; + height: 120px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + border: 2px solid rgba(255, 255, 255, 0.3); +} + +.points-icon { + width: 24px; + height: 24px; + margin-bottom: 0.5rem; + color: #ffd700; +} + +.points-count { + font-size: 2rem; + font-weight: bold; +} + +.points-info h2 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + font-weight: bold; +} + +.points-info p { + margin: 0; + opacity: 0.95; + font-size: 1.1rem; +} + +/* ===================== */ +/* Stats Section */ +/* ===================== */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.stat-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.5rem; + border-radius: 12px; + transition: all 0.3s ease; +} + +.rewards-page.light .stat-card { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .stat-card { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +.stat-icon { + width: 32px; + height: 32px; + color: #ff6b6b; +} + +.stat-info { + display: flex; + flex-direction: column; +} + +.stat-number { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 0.25rem; +} + +.stat-label { + font-size: 0.9rem; + color: inherit; + opacity: 0.8; +} + +/* ===================== */ +/* Tabs Navigation */ +/* ===================== */ + +.tab-navigation { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border-radius: 12px; + padding: 0.5rem; +} + +.rewards-page.light .tab-navigation { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .tab-navigation { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.tab-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + transition: all 0.3s ease; + font-size: 1rem; + font-weight: 500; + color: inherit; +} + +.tab-button:hover { + background: rgba(255, 107, 107, 0.12); + color: #ff6b6b; +} + +.tab-button.active { + background: #ff6b6b; + color: white; +} + +/* ===================== */ +/* Rewards Grid */ +/* ===================== */ + +.tab-content { + margin-bottom: 2rem; +} + +.tab-content h3 { + margin-bottom: 1.5rem; + font-size: 1.5rem; + font-weight: bold; +} + +.rewards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.reward-card { + padding: 1.5rem; + border-radius: 12px; + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.rewards-page.light .reward-card { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .reward-card { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.reward-card:hover { + transform: translateY(-2px); + border-color: #ff6b6b; +} + +.reward-card.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reward-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #ff6b6b, #ee5a24); + color: white; + margin-bottom: 1rem; +} + +.reward-info h4 { + margin: 0 0 0.5rem 0; + font-size: 1.2rem; + font-weight: bold; + color: inherit; +} + +.reward-info p { + margin: 0 0 1rem 0; + font-size: 0.95rem; + color: inherit; + opacity: 0.85; +} + +.reward-cost { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + color: #ff6b6b !important; + font-weight: 600; +} + +.redeem-btn { + width: 100%; + padding: 0.75rem; + border: none; + border-radius: 8px; + background: #ff6b6b; + color: white; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.redeem-btn:hover:not(.disabled) { + background: #ee5a24; + transform: translateY(-1px); +} + +.redeem-btn.disabled { + background: #aaa; + cursor: not-allowed; +} + +/* ===================== */ +/* Achievements Grid */ +/* ===================== */ + +.achievements-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.achievement-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.5rem; + border-radius: 12px; + transition: all 0.3s ease; + position: relative; +} + +.rewards-page.light .achievement-card { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .achievement-card { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.achievement-card.unlocked { + border: 2px solid #4ecdc4; +} + +.achievement-card.locked { + opacity: 0.6; +} + +.achievement-icon { + font-size: 2rem; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: linear-gradient(135deg, #4ecdc4, #44a08d); +} + +.achievement-emoji { + font-size: 1.5rem; +} + +.achievement-info h4 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + font-weight: bold; + color: inherit; +} + +.achievement-info p { + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + color: inherit; + opacity: 0.85; +} + +.achievement-points { + display: flex; + align-items: center; + gap: 0.25rem; + color: #ffd700 !important; + font-size: 0.9rem; + font-weight: 600; +} + +.achievement-status { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; +} + +.status-icon.unlocked { + color: #4ecdc4; +} + +.status-icon.locked { + color: #aaa; +} + +/* ===================== */ +/* History Section */ +/* ===================== */ + +.history-section { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.unused-rewards, +.reward-history { + padding: 1.5rem; + border-radius: 12px; +} + +.rewards-page.light .unused-rewards, +.rewards-page.light .reward-history { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .unused-rewards, +.rewards-page.dark .reward-history { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.rewards-list, +.history-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.reward-item, +.history-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border-radius: 8px; + transition: all 0.3s ease; +} + +.rewards-page.light .reward-item, +.rewards-page.light .history-item { + background: #f8f9fa; +} + +.rewards-page.dark .reward-item, +.rewards-page.dark .history-item { + background: #3a3a3a; +} + +.reward-item:hover, +.history-item:hover { + transform: translateX(4px); +} + +.reward-details h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: inherit; +} + +.reward-details p { + margin: 0 0 0.25rem 0; + font-size: 0.9rem; + color: inherit; + opacity: 0.85; +} + +.reward-status { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; +} + +.reward-status.active { + background: #d4edda; + color: #155724; +} + +.reward-status.used { + background: #f8d7da; + color: #721c24; +} + +.no-rewards, +.no-history { + text-align: center; + font-style: italic; + padding: 2rem; + opacity: 0.85; +} + +/* ===================== */ +/* How it Works Section */ +/* ===================== */ + +.how-it-works { + margin-top: 3rem; + padding: 2rem; + border-radius: 12px; +} + +.rewards-page.light .how-it-works { + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.rewards-page.dark .how-it-works { + background: #2b2b2b; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.how-it-works h3 { + margin-bottom: 1.5rem; + text-align: center; + font-weight: bold; +} + +.earning-methods { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.earning-method { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border-radius: 8px; + transition: all 0.3s ease; +} + +.rewards-page.light .earning-method { + background: #f8f9fa; +} + +.rewards-page.dark .earning-method { + background: #3a3a3a; +} + +.earning-method:hover { + transform: translateY(-2px); +} + +.method-icon { + width: 32px; + height: 32px; + color: #ff6b6b; +} + +.method-info h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: inherit; +} + +.method-info p { + margin: 0; + font-size: 0.9rem; + color: inherit; + opacity: 0.85; +} + +/* ===================== */ +/* Login / Loading */ +/* ===================== */ + +.login-required { + text-align: center; + padding: 4rem 2rem; +} + +.login-icon { + color: #ff6b6b; + margin-bottom: 1rem; +} + +.login-required h2 { + margin-bottom: 1rem; + font-size: 2rem; + font-weight: bold; +} + +.login-required p { + font-size: 1.1rem; + color: inherit; + opacity: 0.9; +} + +.loading { + text-align: center; + padding: 4rem 2rem; + font-size: 1.2rem; + color: #ff6b6b; +} + +/* ===================== */ +/* Responsive Design */ +/* ===================== */ + +@media (max-width: 768px) { + .rewards-container { + padding: 0 0.5rem; + } + + .points-display { + flex-direction: column; + text-align: center; + gap: 1rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .tab-navigation { + flex-direction: column; + } + + .rewards-grid, + .achievements-grid { + grid-template-columns: 1fr; + } + + .earning-methods { + grid-template-columns: 1fr; + } + + .history-section { + gap: 1rem; + } +} diff --git a/frontend/src/pages/Rewards/Rewards.jsx b/frontend/src/pages/Rewards/Rewards.jsx new file mode 100644 index 00000000..368f60b0 --- /dev/null +++ b/frontend/src/pages/Rewards/Rewards.jsx @@ -0,0 +1,327 @@ +import React, { useContext, useState } from 'react'; +import { LoyaltyContext } from '../../components/context/LoyaltyContext'; +import { StoreContext } from '../../components/context/StoreContext'; +import { ThemeContext } from '../../components/context/ThemeContext'; +import { + Gift, + Star, + Trophy, + Crown, + Zap, + Target, + Award, + ShoppingBag, + Percent, + Truck +} from 'lucide-react'; +import './Rewards.css'; + +const Rewards = () => { + const { theme } = useContext(ThemeContext); + const { user, isAuthenticated } = useContext(StoreContext); + const { + userPoints, + achievements, + rewardHistory, + REWARDS_CATALOG, + ACHIEVEMENTS, + redeemReward, + getAvailableRewards, + getUnusedRedemptions, + loading + } = useContext(LoyaltyContext); + + const [activeTab, setActiveTab] = useState('rewards'); + + if (!isAuthenticated) { + return ( +
+
+
+ +

Login Required

+

Please login to view your rewards and achievements

+
+
+
+ ); + } + + if (loading) { + return ( +
+
+
Loading your rewards...
+
+
+ ); + } + + const availableRewards = getAvailableRewards(); + const unusedRedemptions = getUnusedRedemptions(); + + const handleRedeemReward = (rewardId) => { + const redemption = redeemReward(rewardId); + if (redemption) { + // Reward redeemed successfully + } + }; + + const getRewardIcon = (type) => { + switch (type) { + case 'discount': + return ; + case 'free_delivery': + return ; + case 'cashback': + return ; + default: + return ; + } + }; + + const getAchievementIcon = (icon) => { + return {icon}; + }; + + return ( +
+
+ {/* Header Section */} +
+
+
+ + {userPoints} +
+
+

Your Points

+

Earn points with every order and unlock amazing rewards!

+
+
+ +
+
+ +
+ {achievements.length} + Achievements +
+
+
+ +
+ {rewardHistory.length} + Rewards Claimed +
+
+
+ +
+ {availableRewards.length} + Available Rewards +
+
+
+
+ + {/* Tab Navigation */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'rewards' && ( +
+

Available Rewards

+
+ {REWARDS_CATALOG.map(reward => { + const canAfford = userPoints >= reward.pointsCost; + return ( +
+
+ {getRewardIcon(reward.type)} +
+
+

{reward.name}

+

{reward.description}

+
+ + {reward.pointsCost} points +
+
+ +
+ ); + })} +
+
+ )} + + {activeTab === 'achievements' && ( +
+

Achievements

+
+ {ACHIEVEMENTS.map(achievement => { + const isUnlocked = achievements.some(a => a.id === achievement.id); + return ( +
+
+ {getAchievementIcon(achievement.icon)} +
+
+

{achievement.name}

+

{achievement.description}

+
+ + {achievement.points} points +
+
+
+ {isUnlocked ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+ )} + + {activeTab === 'history' && ( +
+
+

Active Rewards

+ {unusedRedemptions.length > 0 ? ( +
+ {unusedRedemptions.map(redemption => { + const reward = REWARDS_CATALOG.find(r => r.id === redemption.rewardId); + return ( +
+
+ {getRewardIcon(reward?.type)} +
+
+

{redemption.rewardName}

+

Redeemed on {new Date(redemption.redeemedAt).toLocaleDateString()}

+ Ready to use +
+
+ ); + })} +
+ ) : ( +

No active rewards. Redeem some rewards to see them here!

+ )} +
+ +
+

Reward History

+ {rewardHistory.length > 0 ? ( +
+ {rewardHistory.map(redemption => { + const reward = REWARDS_CATALOG.find(r => r.id === redemption.rewardId); + return ( +
+
+ {getRewardIcon(reward?.type)} +
+
+

{redemption.rewardName}

+

Redeemed on {new Date(redemption.redeemedAt).toLocaleDateString()}

+ + {redemption.used ? 'Used' : 'Active'} + +
+
+ + {redemption.pointsCost} points +
+
+ ); + })} +
+ ) : ( +

No reward history yet. Start redeeming rewards!

+ )} +
+
+ )} +
+ + {/* How it Works Section */} +
+

How to Earn Points

+
+
+ +
+

Place Orders

+

Earn 1 point for every $1 spent

+
+
+
+ +
+

Write Reviews

+

Get 25 points for each review

+
+
+
+ +
+

Unlock Achievements

+

Bonus points for milestones

+
+
+
+ +
+

Refer Friends

+

200 points for each referral

+
+
+
+
+
+
+ ); +}; + +export default Rewards; \ No newline at end of file