Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/screenshots/promo-applied.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/promo-input.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/promo-typed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions BACKEND/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import wishlistRoutes from "./routes/wishlist.route.js";
import newsletterRoutes from "./routes/newsletter.route.js";
import ordersRoutes from "./routes/orders.route.js";
import userRoutes from "./routes/user.route.js";
import couponRoutes from "./routes/coupon.route.js";
import analyticsRoutes from "./routes/analytics.route.js";
import referralRoutes from "./routes/referral.route.js";
import passport from "./config/passport.js";
Expand Down Expand Up @@ -113,6 +114,7 @@ app.use("/api/wishlist", wishlistRoutes);
app.use("/api/orders", ordersRoutes);
app.use("/api/user", userRoutes);
app.use("/api/newsletter", newsletterRoutes);
app.use("/api/coupons", couponRoutes);
app.use("/api/admin/analytics", analyticsRoutes);
app.use("/api/referrals", referralRoutes);

Expand Down
44 changes: 43 additions & 1 deletion BACKEND/controllers/checkout.controller.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Product from '../models/product.model.js';
import Order from '../models/order.model.js';
import Coupon from '../models/coupon.model.js';
import mongoose from 'mongoose';
import Stripe from 'stripe';
import { processReferralOnPurchase } from '../services/referral.service.js';
Expand Down Expand Up @@ -29,7 +30,7 @@ async function restoreStock(deductions) {

export const createCheckoutSession = async (req, res) => {
try {
const { items } = req.body;
const { items, couponCode } = req.body;

if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "Cart is empty or invalid" });
Expand Down Expand Up @@ -78,6 +79,37 @@ export const createCheckoutSession = async (req, res) => {
});
}

// Validate and apply coupon discount if provided
let couponDoc = null;
let discountAmount = 0;
if (couponCode) {
const rawTotal = lineItems.reduce((sum, li) => sum + li.price_data.unit_amount * li.quantity, 0) / 100;
couponDoc = await Coupon.findOne({ code: couponCode.trim().toUpperCase(), isActive: true });

if (!couponDoc) {
return res.status(400).json({ success: false, message: 'Invalid or expired coupon code' });
}
if (couponDoc.expiresAt && new Date() > couponDoc.expiresAt) {
return res.status(400).json({ success: false, message: 'This coupon has expired' });
}
if (couponDoc.maxUses !== null && couponDoc.usedCount >= couponDoc.maxUses) {
return res.status(400).json({ success: false, message: 'Coupon usage limit reached' });
}
if (rawTotal < couponDoc.minOrderAmount) {
return res.status(400).json({ success: false, message: `Minimum order of $${couponDoc.minOrderAmount.toFixed(2)} required` });
}

discountAmount = couponDoc.type === 'percentage'
? (rawTotal * couponDoc.value) / 100
: Math.min(couponDoc.value, rawTotal);

// Apply discount by scaling each line item's unit_amount proportionally
const ratio = 1 - discountAmount / rawTotal;
for (const li of lineItems) {
li.price_data.unit_amount = Math.max(1, Math.round(li.price_data.unit_amount * ratio));
}
}

const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
Expand All @@ -89,6 +121,8 @@ export const createCheckoutSession = async (req, res) => {
items.map((item) => ({ _id: item._id, quantity: item.quantity }))
),
userId: req.user?._id?.toString() || '',
couponCode: couponDoc ? couponDoc.code : '',
discountAmount: discountAmount.toFixed(2),
},
});

Expand Down Expand Up @@ -183,6 +217,14 @@ export const stripeWebhook = async (req, res) => {
paymentStatus: "completed",
});

// Increment coupon usage after successful fulfillment
if (session.metadata?.couponCode) {
await Coupon.findOneAndUpdate(
{ code: session.metadata.couponCode },
{ $inc: { usedCount: 1 } }
);
}

// Trigger referral reward
if (order.user) {
processReferralOnPurchase(order._id).catch(err => {
Expand Down
91 changes: 91 additions & 0 deletions BACKEND/controllers/coupon.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Coupon from '../models/coupon.model.js';

export const validateCoupon = async (req, res) => {
try {
const { code, orderTotal } = req.body;
if (!code) return res.status(400).json({ success: false, message: 'Coupon code is required' });

const coupon = await Coupon.findOne({ code: code.trim().toUpperCase(), isActive: true });

if (!coupon) return res.status(404).json({ success: false, message: 'Invalid or expired coupon code' });

if (coupon.expiresAt && new Date() > coupon.expiresAt) {
return res.status(400).json({ success: false, message: 'This coupon has expired' });
}

if (coupon.maxUses !== null && coupon.usedCount >= coupon.maxUses) {
return res.status(400).json({ success: false, message: 'This coupon has reached its usage limit' });
}

const total = Number(orderTotal) || 0;
if (total < coupon.minOrderAmount) {
return res.status(400).json({
success: false,
message: `Minimum order amount of $${coupon.minOrderAmount.toFixed(2)} required`,
});
}

const discount =
coupon.type === 'percentage'
? (total * coupon.value) / 100
: Math.min(coupon.value, total);

res.json({
success: true,
data: {
code: coupon.code,
type: coupon.type,
value: coupon.value,
discount: Math.round(discount * 100) / 100,
finalTotal: Math.round((total - discount) * 100) / 100,
},
});
} catch (err) {
console.error('[Coupon] validate error:', err.message);
res.status(500).json({ success: false, message: 'Failed to validate coupon' });
}
};

// Admin: create coupon
export const createCoupon = async (req, res) => {
try {
const { code, type, value, minOrderAmount, maxUses, expiresAt } = req.body;
if (!code || !type || value === undefined) {
return res.status(400).json({ success: false, message: 'code, type, and value are required' });
}
if (type === 'percentage' && (value <= 0 || value > 100)) {
return res.status(400).json({ success: false, message: 'Percentage must be between 1 and 100' });
}
const coupon = await Coupon.create({ code, type, value, minOrderAmount, maxUses, expiresAt });
res.status(201).json({ success: true, data: coupon });
} catch (err) {
if (err.code === 11000) return res.status(409).json({ success: false, message: 'Coupon code already exists' });
console.error('[Coupon] create error:', err.message);
res.status(500).json({ success: false, message: 'Failed to create coupon' });
}
};

// Admin: list all coupons
export const listCoupons = async (req, res) => {
try {
const coupons = await Coupon.find().sort({ createdAt: -1 });
res.json({ success: true, data: coupons });
} catch (err) {
res.status(500).json({ success: false, message: 'Failed to fetch coupons' });
}
};

// Admin: deactivate coupon
export const deactivateCoupon = async (req, res) => {
try {
const coupon = await Coupon.findOneAndUpdate(
{ code: req.params.code.toUpperCase() },
{ isActive: false },
{ new: true }
);
if (!coupon) return res.status(404).json({ success: false, message: 'Coupon not found' });
res.json({ success: true, data: coupon });
} catch (err) {
res.status(500).json({ success: false, message: 'Failed to deactivate coupon' });
}
};
52 changes: 52 additions & 0 deletions BACKEND/models/coupon.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import mongoose from 'mongoose';

const couponSchema = new mongoose.Schema(
{
code: {
type: String,
required: [true, 'Coupon code is required'],
unique: true,
uppercase: true,
trim: true,
minlength: [3, 'Code must be at least 3 characters'],
maxlength: [20, 'Code cannot exceed 20 characters'],
},
type: {
type: String,
enum: ['percentage', 'flat'],
required: true,
},
value: {
type: Number,
required: true,
min: [0, 'Discount value cannot be negative'],
},
minOrderAmount: {
type: Number,
default: 0,
min: 0,
},
maxUses: {
type: Number,
default: null, // null = unlimited
},
usedCount: {
type: Number,
default: 0,
},
expiresAt: {
type: Date,
default: null, // null = never expires
},
isActive: {
type: Boolean,
default: true,
},
},
{ timestamps: true }
);

couponSchema.index({ code: 1 });

const Coupon = mongoose.model('Coupon', couponSchema);
export default Coupon;
20 changes: 20 additions & 0 deletions BACKEND/routes/coupon.route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import express from 'express';
import { validateCoupon, createCoupon, listCoupons, deactivateCoupon } from '../controllers/coupon.controller.js';
import authMiddleware from '../middleware/authMiddleware.js';

const isAdmin = (req, res, next) => {
if (req.user?.role !== 'admin') return res.status(403).json({ success: false, message: 'Admins only' });
next();
};

const router = express.Router();

// Public: validate a coupon during checkout
router.post('/validate', validateCoupon);

// Admin: manage coupons
router.post('/', authMiddleware, isAdmin, createCoupon);
router.get('/', authMiddleware, isAdmin, listCoupons);
router.patch('/:code/deactivate', authMiddleware, isAdmin, deactivateCoupon);

export default router;
76 changes: 69 additions & 7 deletions FRONTEND/src/components/ui/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import {
Button, Container, Flex, HStack, Text, Input, useColorMode, useDisclosure,
Drawer, DrawerBody, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton,
VStack, Box, Badge, useColorModeValue, useToast
VStack, Box, Badge, useColorModeValue, useToast, InputGroup, InputRightElement, Tag, TagLabel, TagCloseButton
} from '@chakra-ui/react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -35,6 +35,9 @@ const Navbar = () => {
const isAdmin = user?.role === 'admin';
const [isCheckoutLoading, setIsCheckoutLoading] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [promoInput, setPromoInput] = useState('');
const [promoLoading, setPromoLoading] = useState(false);
const [appliedCoupon, setAppliedCoupon] = useState(null); // { code, discount, finalTotal }

const totalItemsCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);

Expand All @@ -61,6 +64,30 @@ const Navbar = () => {
return () => window.removeEventListener('open-cart', handleOpenCart);
}, [handleCartOpen]);

const handleApplyPromo = async () => {
if (!promoInput.trim()) return;
setPromoLoading(true);
try {
const res = await fetch('/api/coupons/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: promoInput.trim(), orderTotal: totalPrice ?? 0 }),
});
const data = await res.json();
if (!data.success) {
toast({ title: 'Invalid coupon', description: data.message, status: 'error', duration: 3000, isClosable: true });
return;
}
setAppliedCoupon(data.data);
setPromoInput('');
toast({ title: `Coupon applied!`, description: `You save $${data.data.discount.toFixed(2)}`, status: 'success', duration: 3000, isClosable: true });
} catch {
toast({ title: 'Error', description: 'Could not validate coupon', status: 'error', duration: 3000, isClosable: true });
} finally {
setPromoLoading(false);
}
};

const handleCheckout = async () => {
if (cartItems.length === 0) return;
setIsCheckoutLoading(true);
Expand All @@ -69,7 +96,7 @@ const Navbar = () => {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: cartItems }),
body: JSON.stringify({ items: cartItems, couponCode: appliedCoupon?.code || null }),
});
if (!res.ok) {
throw new Error(`Server error: ${res.status} ${res.statusText}`);
Expand All @@ -86,6 +113,7 @@ const Navbar = () => {
});
return;
}
setAppliedCoupon(null);
onClose();
window.location.href = data.url;
} catch (err) {
Expand Down Expand Up @@ -445,12 +473,46 @@ const Navbar = () => {
)}
</DrawerBody>

<DrawerFooter borderTopWidth="1px" display="flex" flexDirection="column" alignItems="stretch">
<HStack justify="space-between" mb={4}>
<DrawerFooter borderTopWidth="1px" display="flex" flexDirection="column" alignItems="stretch" gap={3}>
{/* Promo code input */}
{cartItems.length > 0 && (
appliedCoupon ? (
<HStack justify="space-between">
<Tag colorScheme="green" size="md" borderRadius="full">
<TagLabel>{appliedCoupon.code} — save ${appliedCoupon.discount.toFixed(2)}</TagLabel>
<TagCloseButton onClick={() => setAppliedCoupon(null)} />
</Tag>
</HStack>
) : (
<InputGroup size="sm">
<Input
placeholder="Promo code"
value={promoInput}
onChange={(e) => setPromoInput(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && handleApplyPromo()}
textTransform="uppercase"
/>
<InputRightElement width="4.5rem">
<Button h="1.5rem" size="xs" colorScheme="cyan" onClick={handleApplyPromo} isLoading={promoLoading}>
Apply
</Button>
</InputRightElement>
</InputGroup>
)
)}

<HStack justify="space-between">
<Text fontWeight="bold" fontSize="lg">{t('cart.total')}:</Text>
<Text fontWeight="bold" fontSize="lg" color="cyan.500">
{formatPrice(totalPrice ?? 0, currency, rates)}
</Text>
<VStack align="flex-end" spacing={0}>
{appliedCoupon && (
<Text fontSize="sm" color="gray.400" textDecoration="line-through">
{formatPrice(totalPrice ?? 0, currency, rates)}
</Text>
)}
<Text fontWeight="bold" fontSize="lg" color="cyan.500">
{formatPrice(appliedCoupon ? appliedCoupon.finalTotal : (totalPrice ?? 0), currency, rates)}
</Text>
</VStack>
</HStack>
<Button colorScheme="blue" size="lg" width="100%" onClick={handleCheckout} isLoading={isCheckoutLoading} isDisabled={cartItems.length === 0}>
Proceed to Checkout
Expand Down
Loading