diff --git a/admin/src/components/Navbar/Navbar.css b/admin/src/components/Navbar/Navbar.css index d76e8e4..ebe6370 100644 --- a/admin/src/components/Navbar/Navbar.css +++ b/admin/src/components/Navbar/Navbar.css @@ -1,15 +1,59 @@ -.navbar{ +/* Navbar Container */ +.navbar { display: flex; align-items: center; justify-content: space-between; - padding: 10px 4%; - - color: white; + padding: 12px 5%; + background-color: #111; /* Dark background */ + color: #fff; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 1000; } -.navbar .logo{ - width: max(10%,80px); + +/* Logo Styling */ +.navbar .logo { + width: max(10%, 80px); + max-height: 50px; + object-fit: contain; + cursor: pointer; + transition: transform 0.2s ease; } -.navbar .profile -{ + +.navbar .logo:hover { + transform: scale(1.05); +} + +/* Profile Image / Icon */ +.navbar .profile { width: 40px; -} \ No newline at end of file + height: 40px; + border-radius: 50%; + object-fit: cover; + cursor: pointer; + border: 2px solid #fff; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.navbar .profile:hover { + transform: scale(1.1); + box-shadow: 0 0 5px rgba(255, 255, 255, 0.5); +} + +/* Responsive Navbar */ +@media (max-width: 768px) { + .navbar { + padding: 10px 3%; + } + + .navbar .logo { + width: 70px; + } + + .navbar .profile { + width: 35px; + height: 35px; + } +} diff --git a/admin/src/pages/List/List.css b/admin/src/pages/List/List.css index 36ff216..21c3ef3 100644 --- a/admin/src/pages/List/List.css +++ b/admin/src/pages/List/List.css @@ -1,26 +1,53 @@ -.list-table-format{ +/* General List/Table Format */ +.list-table-format { display: grid; grid-template-columns: 0.5fr 2fr 1fr 1fr 0.5fr; align-items: center; - gap: 10px; + gap: 12px; padding: 12px 15px; border: 1px solid #cacaca; + border-radius: 8px; font-size: 13px; + background-color: #fff; + transition: background-color 0.3s ease, box-shadow 0.3s ease; } -.list-table-format.title{ - background-color: #f9f9f9; +.list-table-format:hover { + background-color: #f5f5f5; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); } -.list-table-format img{ + +/* Title Row Styling */ +.list-table-format.title { + background-color: #f9f9f9; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Images inside the list */ +.list-table-format img { width: 50px; + height: 50px; + object-fit: cover; + border-radius: 5px; } + +/* Responsive Styling */ @media (max-width: 600px) { - .list-table-format{ + .list-table-format { grid-template-columns: 1fr 3fr 1fr; - gap: 15px; + gap: 10px; + padding: 10px 12px; } - .list-table-format .title{ + + /* Hide the title row on small screens */ + .list-table-format.title { display: none; } - -} \ No newline at end of file + + .list-table-format img { + width: 40px; + height: 40px; + } +} diff --git a/admin/src/pages/add/Add.css b/admin/src/pages/add/Add.css index c321948..5d53aef 100644 --- a/admin/src/pages/add/Add.css +++ b/admin/src/pages/add/Add.css @@ -1,52 +1,124 @@ -.add{ +/* Container for Add Section */ +.add { width: 70%; - margin-left: max(5vw,25px); + margin-left: max(5vw, 25px); margin-top: 50px; color: #6d6d6d; font-size: 16px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } -.add form{ - gap: 20px; + +/* Form Styling */ +.add form { display: flex; flex-direction: column; - margin-top: 20px; - margin-bottom: 20px; - padding: 20px; - border-radius: 10px; - background-color: #f9f9f9; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + gap: 20px; + margin: 20px 0; + padding: 25px; + border-radius: 12px; + background-color: #fefefe; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); border: 1px solid #e0e0e0; + transition: all 0.3s ease; } -.flex-col{ +.add form:hover { + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); +} + +/* Flex Column Utility */ +.flex-col { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; } -.add-img-upload img{ + +/* Image Upload Styling */ +.add-img-upload img { width: 120px; - justify-items: center; + display: block; + margin: 0 auto; + border-radius: 8px; + object-fit: cover; } -.add-product-name .add-product-description{ - width: max(40%,280px); + +/* Product Name & Description */ +.add-product-name, +.add-product-description { + width: min(40%, 280px); } -.add-product-name input, .add-product-description textarea{ - padding: 10px; + +.add-product-name input, +.add-product-description textarea { + width: 100%; + padding: 12px; + border-radius: 8px; + border: 1px solid #ccc; + font-size: 15px; + transition: border 0.3s ease, box-shadow 0.3s ease; } -.add-category-price{ + +.add-product-name input:focus, +.add-product-description textarea:focus { + border-color: #555; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + outline: none; +} + +/* Category & Price Section */ +.add-category-price { display: flex; gap: 30px; flex-wrap: wrap; -} -.add-category-price input, .add-category-price select{ - padding: 10px; - max-width: 120px; } -.add-btn{ - max-width: 120px; + +.add-category-price input, +.add-category-price select { padding: 10px; + max-width: 140px; + border-radius: 8px; + border: 1px solid #ccc; + font-size: 14px; + transition: border 0.3s ease, box-shadow 0.3s ease; +} + +.add-category-price input:focus, +.add-category-price select:focus { + border-color: #555; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + outline: none; +} + +/* Submit Button */ +.add-btn { + max-width: 140px; + padding: 12px; border: none; - background-color: black; - color: white; + border-radius: 8px; + background-color: #000; + color: #fff; + font-weight: 500; cursor: pointer; -} \ No newline at end of file + transition: background-color 0.3s ease, transform 0.2s ease; +} + +.add-btn:hover { + background-color: #333; + transform: translateY(-2px); +} + +/* Responsive */ +@media (max-width: 768px) { + .add { + width: 90%; + margin-left: 5%; + } + .add-category-price { + flex-direction: column; + gap: 15px; + } + .add-product-name, + .add-product-description { + width: 100%; + } +} diff --git a/admin/src/pages/order/Order.css b/admin/src/pages/order/Order.css index 208b7d2..e2cf533 100644 --- a/admin/src/pages/order/Order.css +++ b/admin/src/pages/order/Order.css @@ -1,60 +1,101 @@ +/* Container for Order Management */ .order-management { - padding: 20px; + padding: 25px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: #333; } +/* Search Bar */ .search-bar { - margin-bottom: 20px; + margin-bottom: 20px; } .search-bar input { - width: 100%; - padding: 10px; - font-size: 16px; - border: 1px solid #ccc; - border-radius: 4px; + width: 100%; + padding: 12px 15px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 8px; + transition: all 0.3s ease; } +.search-bar input:focus { + outline: none; + border-color: #555; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +/* Orders Table */ .orders-table { - overflow-x: auto; + overflow-x: auto; + border-radius: 8px; } .orders-table table { - width: 100%; - border-collapse: collapse; + width: 100%; + border-collapse: collapse; + min-width: 600px; } .orders-table th, .orders-table td { - padding: 12px; - text-align: left; - border-bottom: 1px solid #ddd; + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #e0e0e0; } .orders-table th { - background-color: #f2f2f2; + background-color: #f9f9f9; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; } +.orders-table tr:hover { + background-color: #f5f5f5; +} + +/* Pagination */ .pagination { - display: flex; - justify-content: center; - align-items: center; - margin-top: 20px; - gap: 10px; + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; + gap: 10px; } .pagination button { - padding: 8px 16px; - border: 1px solid #ccc; - background-color: #fff; - cursor: pointer; - border-radius: 4px; + padding: 8px 16px; + border: 1px solid #ccc; + background-color: #fff; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s ease; +} + +.pagination button:hover:not(:disabled) { + background-color: #f0f0f0; + transform: translateY(-1px); } .pagination button:disabled { - background-color: #f2f2f2; - cursor: not-allowed; + background-color: #f2f2f2; + cursor: not-allowed; } .pagination span { - font-weight: bold; + font-weight: 600; + font-size: 14px; +} + +/* Responsive */ +@media (max-width: 768px) { + .orders-table table { + font-size: 14px; + } + + .pagination button { + padding: 6px 12px; + font-size: 14px; + } } diff --git a/backend/config/db.js b/backend/config/db.js index ea8ffa9..46c707c 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -3,21 +3,52 @@ import dotenv from "dotenv"; dotenv.config(); +const MAX_RETRIES = 5; // Maximum number of retries +const RETRY_DELAY = 2000; // Delay between retries in ms + export const connectDB = async () => { - if (!process.env.MONGO_URI) { + const mongoUri = process.env.MONGO_URI; + const dbName = process.env.DB_NAME || "food-del"; + + if (!mongoUri) { console.error("❌ MONGO_URI environment variable is required"); process.exit(1); } - try { - await mongoose.connect(process.env.MONGO_URI, { - dbName: process.env.DB_NAME || "food-del", - retryWrites: true, - w: "majority", - }); - console.log("✅ MongoDB connected successfully"); - } catch (err) { + let retries = 0; + + const connectWithRetry = async () => { + try { + await mongoose.connect(mongoUri, { + dbName, + retryWrites: true, + w: "majority", + }); + console.log("✅ MongoDB connected successfully"); + } catch (err) { + retries += 1; + console.error(`❌ MongoDB connection error (attempt ${retries}):`, err.message); + + // Check if maximum retries reached + if (retries >= MAX_RETRIES) { + console.error("❌ Maximum retries reached. Could not connect to MongoDB."); + process.exit(1); + } + + console.log(`🔄 Retrying in ${RETRY_DELAY / 1000} seconds...`); + setTimeout(connectWithRetry, RETRY_DELAY); + } + }; + + await connectWithRetry(); + + // Optional: handle disconnect events + mongoose.connection.on("disconnected", () => { + console.warn("⚠️ MongoDB disconnected. Trying to reconnect..."); + connectWithRetry(); + }); + + mongoose.connection.on("error", (err) => { console.error("❌ MongoDB connection error:", err); - process.exit(1); - } + }); }; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index bbf9f97..f19968d 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,24 +1,3 @@ -// import jwt from "jsonwebtoken"; - -// const authMiddleware = async (req, res, next) => { -// try { -// const authHeader = req.headers.authorization; -// if (!authHeader || !authHeader.startsWith('Bearer ')) { -// return res.status(401).json({ success: false, message: "Not Authorized. Login Again." }); -// } - -// const token = authHeader.split(' ')[1]; -// const decoded = jwt.verify(token, process.env.JWT_SECRET); - -// req.body.userId = decoded.id; -// next(); -// } catch (error) { -// console.error("JWT Auth Error:", error); -// res.status(401).json({ success: false, message: "Invalid Token" }); -// } -// }; - -// export default authMiddleware; import jwt from "jsonwebtoken"; import userModel from "../models/userModel.js"; @@ -33,22 +12,44 @@ const authMiddleware = async (req, res, next) => { } const token = authHeader.split(" ")[1]; - const decoded = jwt.verify(token, process.env.JWT_SECRET); - // ✅ Fetch user from DB and attach to request + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + if (err.name === "TokenExpiredError") { + return res + .status(401) + .json({ success: false, message: "Token expired. Please login again." }); + } + return res + .status(401) + .json({ success: false, message: "Invalid Token" }); + } + + // Fetch user from DB const user = await userModel.findById(decoded.id).select("-password"); if (!user) { - return res - .status(404) - .json({ success: false, message: "User not found" }); + return res.status(404).json({ success: false, message: "User not found" }); } - req.user = user; // ✅ attach whole user object + req.user = user; // attach whole user object next(); } catch (error) { console.error("JWT Auth Error:", error); - res.status(401).json({ success: false, message: "Invalid Token" }); + next(error); // pass to global error handler + } +}; + +// Optional: middleware for role-based authorization +export const authorizeRoles = (...roles) => (req, res, next) => { + if (!roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: "You do not have permission to access this resource", + }); } + next(); }; export default authMiddleware; diff --git a/backend/middleware/security.js b/backend/middleware/security.js index fcdb7df..a2ec96e 100644 --- a/backend/middleware/security.js +++ b/backend/middleware/security.js @@ -1,9 +1,11 @@ import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; +import xss from 'xss'; // Rate limiting for authentication routes export const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // Limit each IP to 5 requests per windowMs + max: 5, // Limit each IP message: { success: false, message: 'Too many authentication attempts. Please try again after 15 minutes.', @@ -12,10 +14,10 @@ export const authRateLimit = rateLimit({ legacyHeaders: false, }); -// General rate limiting for API routes +// General API rate limiting export const apiRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // Limit each IP to 100 requests per windowMs + windowMs: 15 * 60 * 1000, + max: 100, message: { success: false, message: 'Too many requests. Please try again after 15 minutes.', @@ -24,46 +26,41 @@ export const apiRateLimit = rateLimit({ legacyHeaders: false, }); -// Security headers middleware -export const securityHeaders = (req, res, next) => { - // Remove X-Powered-By header - res.removeHeader('X-Powered-By'); - - // Set security headers - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - - // Only set HSTS in production - if (process.env.NODE_ENV === 'production') { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - - next(); -}; +// Security headers middleware using helmet +export const securityHeaders = helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + }, + frameguard: { action: 'deny' }, + hsts: process.env.NODE_ENV === 'production' ? { maxAge: 31536000, includeSubDomains: true } : undefined, + noSniff: true, + xssFilter: true, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + permissionsPolicy: { features: { camera: [], microphone: [], geolocation: [] } }, +}); // Input sanitization middleware export const sanitizeInput = (req, res, next) => { - // Remove potential XSS characters from request body const sanitize = (obj) => { - if (typeof obj === 'object' && obj !== null) { + if (obj && typeof obj === 'object') { for (const key in obj) { if (typeof obj[key] === 'string') { - obj[key] = obj[key].replace(/)<[^<]*)*<\/script>/gi, ''); - obj[key] = obj[key].replace(/javascript:/gi, ''); - obj[key] = obj[key].replace(/on\w+\s*=/gi, ''); + obj[key] = xss(obj[key]); // Sanitize string to remove XSS } else if (typeof obj[key] === 'object') { sanitize(obj[key]); } } } }; - - if (req.body) { - sanitize(req.body); - } - + + sanitize(req.body); + sanitize(req.query); + sanitize(req.params); + next(); }; diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 20dce65..90cc26b 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -1,4 +1,4 @@ -import { body, validationResult } from 'express-validator'; +import { body, validationResult } from "express-validator"; // Handle validation errors export const handleValidationErrors = (req, res, next) => { @@ -6,48 +6,44 @@ export const handleValidationErrors = (req, res, next) => { if (!errors.isEmpty()) { return res.status(400).json({ success: false, - message: 'Validation failed', - errors: errors.array() + message: "Validation failed", + errors: errors.array().map(err => ({ + field: err.param, + message: err.msg, + value: err.value, + })), }); } next(); }; +// Reusable validators +const passwordValidator = body('password') + .isLength({ min: 8 }) + .withMessage('Password must be at least 8 characters long') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) + .withMessage('Password must contain at least one uppercase, one lowercase, one number, and one special character'); + +const nameValidator = body('name') + .trim() + .isLength({ min: 2, max: 50 }) + .withMessage('Name must be between 2 and 50 characters') + .matches(/^[a-zA-Z\s]+$/) + .withMessage('Name can only contain letters and spaces'); + // User registration validation export const validateRegistration = [ - body('name') - .trim() - .isLength({ min: 2, max: 50 }) - .withMessage('Name must be between 2 and 50 characters') - .matches(/^[a-zA-Z\s]+$/) - .withMessage('Name can only contain letters and spaces'), - - body('email') - .isEmail() - .normalizeEmail() - .withMessage('Please provide a valid email address'), - - body('password') - .isLength({ min: 8 }) - .withMessage('Password must be at least 8 characters long') - .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) - .withMessage('Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'), - - handleValidationErrors + nameValidator, + body('email').isEmail().normalizeEmail().withMessage('Please provide a valid email'), + passwordValidator, + handleValidationErrors, ]; // User login validation export const validateLogin = [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('Please provide a valid email address'), - - body('password') - .notEmpty() - .withMessage('Password is required'), - - handleValidationErrors + body('email').isEmail().normalizeEmail().withMessage('Please provide a valid email'), + body('password').notEmpty().withMessage('Password is required'), + handleValidationErrors, ]; // Order placement validation @@ -55,45 +51,33 @@ export const validateOrderPlacement = [ body('items') .isArray({ min: 1 }) .withMessage('Order must contain at least one item'), - - body('items.*.foodId') - .isMongoId() - .withMessage('Invalid food item ID'), - - body('items.*.quantity') - .isInt({ min: 1, max: 10 }) - .withMessage('Quantity must be between 1 and 10'), - - body('amount') - .isFloat({ min: 1 }) - .withMessage('Order amount must be greater than 0'), - + body('items.*.foodId').isMongoId().withMessage('Invalid food item ID'), + body('items.*.quantity').isInt({ min: 1, max: 10 }).withMessage('Quantity must be between 1 and 10'), + body('amount').isFloat({ min: 1 }).withMessage('Order amount must be greater than 0'), + body('address.street') + .optional() .trim() .isLength({ min: 5, max: 100 }) .withMessage('Street address must be between 5 and 100 characters'), - body('address.city') + .optional() .trim() .isLength({ min: 2, max: 50 }) .withMessage('City must be between 2 and 50 characters'), - body('address.zipcode') + .optional() .matches(/^[0-9]{6}$/) .withMessage('Please provide a valid 6-digit zipcode'), - - handleValidationErrors + + handleValidationErrors, ]; // Order status update validation export const validateOrderStatusUpdate = [ - body('orderId') - .isMongoId() - .withMessage('Invalid order ID'), - + body('orderId').isMongoId().withMessage('Invalid order ID'), body('status') .isIn(['pending', 'confirmed', 'preparing', 'out_for_delivery', 'delivered', 'cancelled']) .withMessage('Invalid order status'), - - handleValidationErrors + handleValidationErrors, ]; diff --git a/backend/server.js b/backend/server.js index 7971acb..288c975 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,9 @@ import express from "express"; import cors from "cors"; import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + import { apiRateLimit, securityHeaders, sanitizeInput } from "./middleware/security.js"; import { connectDB } from "./config/db.js"; import foodRouter from "./routes/foodRoutes.js"; @@ -8,8 +11,6 @@ import userRouter from "./routes/userRoutes.js"; import cartRouter from "./routes/cartRoutes.js"; import orderRouter from "./routes/orderRoute.js"; import newsletterRoutes from "./routes/newsletter.js"; -import path from "path"; -import { fileURLToPath } from "url"; dotenv.config(); @@ -24,18 +25,17 @@ const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",") : []; -// Security middleware +// Middleware app.use(securityHeaders); app.use(sanitizeInput); app.use(apiRateLimit); -// ✅ CORS middleware app.use( cors({ - origin: function (origin, callback) { - if (!origin) return callback(null, true); // allow Postman, curl + origin: (origin, callback) => { + if (!origin) return callback(null, true); // Allow Postman, curl if (allowedOrigins.includes(origin)) return callback(null, true); - console.log("Blocked CORS request from:", origin); + console.warn("Blocked CORS request from:", origin); return callback(new Error("Not allowed by CORS")); }, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], @@ -43,24 +43,39 @@ app.use( }) ); -app.use(express.json({ limit: '10mb' })); // Add size limit for security - -// Connect to DB -connectDB(); +app.use(express.json({ limit: "10mb" })); +app.use("/images", express.static("uploads")); // Routes app.get("/", (req, res) => res.send("Backend is running!")); app.use("/api/foods", foodRouter); -app.use("/images", express.static("uploads")); app.use("/api/users", userRouter); app.use("/api/carts", cartRouter); app.use("/api/order", orderRouter); app.use("/api/newsletter", newsletterRoutes); - app.use("/auth", userRouter); -// Start server -app.listen(PORT, "0.0.0.0", () => { - console.log(`✅ Server running on port ${PORT}`); - console.log("✅ Allowed origins:", allowedOrigins); +// Global error handler +app.use((err, req, res, next) => { + console.error("❌ Global Error:", err.message || err); + res.status(err.status || 500).json({ + success: false, + message: err.message || "Internal Server Error", + }); }); + +// Connect to DB and start server +const startServer = async () => { + try { + await connectDB(); // Wait for DB connection + app.listen(PORT, "0.0.0.0", () => { + console.log(`✅ Server running on port ${PORT}`); + console.log("✅ Allowed origins:", allowedOrigins); + }); + } catch (err) { + console.error("❌ Failed to start server:", err); + process.exit(1); + } +}; + +startServer(); diff --git a/backend/services/emailService.js b/backend/services/emailService.js index aa4f4a2..8ccb369 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -1,70 +1,84 @@ import nodemailer from "nodemailer"; import crypto from "crypto"; +// ✅ Validate environment variables early +const { EMAIL_USERNAME, EMAIL_PASSWORD, NEWSLETTER_SUBSCRIBE_SECRET, EMAIL_USER } = process.env; + +if (!EMAIL_USERNAME || !EMAIL_PASSWORD || !EMAIL_USER) { + console.error("❌ EMAIL_USERNAME, EMAIL_PASSWORD, and EMAIL_USER are required in .env"); + process.exit(1); +} + +// ✅ Create transporter const transporter = nodemailer.createTransport({ - service: "gmail", // or your SMTP + service: "gmail", auth: { - user: process.env.EMAIL_USERNAME, - pass: process.env.EMAIL_PASSWORD, + user: EMAIL_USERNAME, + pass: EMAIL_PASSWORD, }, }); // ✅ Token generator export const generateUnsubscribeToken = (email) => { + if (!email) throw new Error("Email is required to generate unsubscribe token"); return crypto - .createHmac( - "sha256", - process.env.NEWSLETTER_SUBSCRIBE_SECRET || "default_secret" - ) + .createHmac("sha256", NEWSLETTER_SUBSCRIBE_SECRET || "default_secret") .update(email) .digest("hex"); }; -// ✅ Confirmation email +// ✅ Send confirmation email export const sendConfirmationEmail = async (email, unsubscribeLink) => { + if (!email || !unsubscribeLink) throw new Error("Email and unsubscribeLink are required"); try { await transporter.sendMail({ - from: `"Tomato 🍅" <${process.env.EMAIL_USER}>`, + from: `"Tomato 🍅" <${EMAIL_USER}>`, to: email, subject: "Welcome to Tomato Newsletter", html: `
-

🍴 Thank you for subscribing!

-

- Tomato is your one-stop destination for fast, fresh, and flavorful meals. - We're committed to delivering your favorite dishes from top-rated kitchens - right to your doorstep—hot and on time! -

-

- If you ever wish to unsubscribe, click the button below: -

- - Unsubscribe - -
+

🍴 Thank you for subscribing!

+

+ Tomato is your one-stop destination for fast, fresh, and flavorful meals. + We're committed to delivering your favorite dishes from top-rated kitchens + right to your doorstep—hot and on time! +

+

+ If you ever wish to unsubscribe, click the button below: +

+ + Unsubscribe + + `, }); + console.log(`✅ Confirmation email sent to ${email}`); return true; } catch (err) { - console.error("❌ Email sending failed:", err); + console.error(`❌ Email sending failed to ${email}:`, err.message); return false; } }; -// ✅ Bulk newsletter +// ✅ Bulk newsletter sender with concurrency control export const sendBulkNewsletter = async ( subscribers, subject, content, - generateUnsubscribeLink + generateUnsubscribeLink, + concurrency = 5 ) => { - try { - for (const subscriber of subscribers) { - const unsubscribeLink = generateUnsubscribeLink(subscriber.email); + if (!Array.isArray(subscribers) || subscribers.length === 0) { + console.warn("⚠️ No subscribers to send newsletter to."); + return false; + } + const sendTasks = subscribers.map(async (subscriber) => { + try { + const unsubscribeLink = generateUnsubscribeLink(subscriber.email); await transporter.sendMail({ - from: `"Tomato 🍅" <${process.env.EMAIL_USER}>`, + from: `"Tomato 🍅" <${EMAIL_USER}>`, to: subscriber.email, subject, html: ` @@ -76,10 +90,20 @@ export const sendBulkNewsletter = async (

`, }); + console.log(`✅ Newsletter sent to ${subscriber.email}`); + return { email: subscriber.email, status: "success" }; + } catch (err) { + console.error(`❌ Failed to send newsletter to ${subscriber.email}:`, err.message); + return { email: subscriber.email, status: "failed", error: err.message }; } - return true; - } catch (err) { - console.error("❌ Bulk email sending failed:", err); - return false; + }); + + // Control concurrency + const results = []; + while (sendTasks.length) { + const chunk = sendTasks.splice(0, concurrency); + results.push(...(await Promise.all(chunk))); } + + return results; }; diff --git a/deliveryBoy/src/App.css b/deliveryBoy/src/App.css index b9d355d..97a3838 100644 --- a/deliveryBoy/src/App.css +++ b/deliveryBoy/src/App.css @@ -1,23 +1,33 @@ +/* Root container */ #root { max-width: 1280px; margin: 0 auto; padding: 2rem; text-align: center; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: #333; + line-height: 1.6; } +/* Logo Styling */ .logo { height: 6em; padding: 1.5em; - will-change: filter; - transition: filter 300ms; + will-change: filter, transform; + transition: filter 0.3s ease, transform 0.3s ease; + cursor: pointer; } + .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); + transform: scale(1.05); } + .logo.react:hover { filter: drop-shadow(0 0 2em #61dafbaa); } +/* Spin animation for logo */ @keyframes logo-spin { from { transform: rotate(0deg); @@ -33,10 +43,41 @@ } } +/* Card Styling */ .card { padding: 2em; + margin: 1em auto; + background-color: #fefefe; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 6px 25px rgba(0, 0, 0, 0.08); } +/* Links to documentation */ .read-the-docs { color: #888; + text-decoration: underline; + transition: color 0.3s ease; + font-size: 0.95rem; +} + +.read-the-docs:hover { + color: #646cff; +} + +/* Responsive */ +@media (max-width: 768px) { + .logo { + height: 4.5em; + padding: 1em; + } + + .card { + padding: 1.5em; + } }