diff --git a/backend/src/controllers/bookingController.js b/backend/src/controllers/bookingController.js index 9bf18ce..11706f9 100644 --- a/backend/src/controllers/bookingController.js +++ b/backend/src/controllers/bookingController.js @@ -6,6 +6,7 @@ import User from "../models/user.model.js"; import Staff from "../models/staff.model.js"; import { sendWhatsappOtp } from "../utils/WhatsApp.js"; // We might need a separate template for confirmation import mongoose from "mongoose"; +import Offer from "../models/offer.model.js"; // Helper: Get Time Slots (10 AM - 8 PM) const generateTimeSlots = () => { @@ -93,7 +94,27 @@ export const createBooking = async (req, res) => { const session = await mongoose.startSession(); session.startTransaction(); try { - let { userId, date, timeSlot, services, products, staffId } = req.body; + let { userId, date, timeSlot, services, products, staffId, couponCode } = req.body; + + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const activeUserBooking = await Appointment.findOne({ + userId: userId, + date: { $gte: startOfToday }, // Future or Today + status: { $in: ["confirmed", "booked", "pending"] } // Active Statuses + }); + + if (activeUserBooking) { + return res.status(409).json({ + message: "You already have an upcoming appointment. Please complete it before booking a new one." + }); + } if (!staffId) { // Find a staff member who is active AND free at this time @@ -107,10 +128,7 @@ export const createBooking = async (req, res) => { }).distinct("staff"); // Get list of busy staff IDs // Finding someone who is NOT in the busy list - const availableStaff = activeStaff.find( - (s) => - !busyStaffIds.map((id) => id.toString()).includes(s._id.toString()), - ); + const availableStaff = activeStaff.find((s) => !busyStaffIds.map((id) => id.toString()).includes(s._id.toString())); if (!availableStaff) { return res @@ -142,43 +160,98 @@ export const createBooking = async (req, res) => { }); } - // Calculate totlal Price (Securely from Backend) let totalAmount = 0; - // Services - // if (!Array.isArray(services) || services.length === 0) { - // return res - // .status(400) - // .json({ message: "Services must be a non-empty array" }); - // } + const hasGeneralMembership = user.subscriptions && user.subscriptions.some( + (sub) => sub.name === "Salon Membership" && sub.isActive && new Date(sub.expiry) > new Date() + ); + + // Process Services if (Array.isArray(services) && services.length > 0) { for (const item of services) { - const service = await Service.findOne({ - _id: item.serviceId, - isActive: true, - }); + const service = await Service.findOne({ _id: item.serviceId, isActive: true }); if (!service) throw new Error(`Service not available: ${item.serviceId}`); - totalAmount += - item.variant === "male" ? service.prices.male : service.prices.female; + let price = item.variant === "male" ? service.prices.male : service.prices.female; + + // CHECK SUBSCRIPTIONS + const hasSpecificSub = user.subscriptions && user.subscriptions.some( + (sub) => sub.isActive && + new Date(sub.expiry) > new Date() && + service.name.toLowerCase() === sub.name.toLowerCase() + ); + + if (hasSpecificSub) { + price = 0; // Free if covered by specific subscription + } else if (hasGeneralMembership) { + price = price * 0.80; // Apply 20% discount + } + + totalAmount += price; } } - // Products if (products && products.length > 0) { for (const prodId of products) { const product = await Product.findOne({ _id: prodId, isActive: true }); if (!product) throw new Error(`Product not available: ${prodId}`); + totalAmount += product.price; + await Product.findByIdAndUpdate(prodId, { $inc: { stock: -1 } }, { session }); + } + } - await Product.findByIdAndUpdate( - prodId, - { $inc: { stock: -1 } }, - { session }, - ); + if (couponCode) { + const offer = await Offer.findOne({ code: couponCode.toUpperCase(), isActive: true }); + + // Validate Expiry + if (offer) { + const isExpired = offer.expiryDate && new Date(offer.expiryDate) < new Date(); + + if (!isExpired) { + let discountAmount = 0; + if (offer.type === "percentage") { + discountAmount = (totalAmount * offer.value) / 100; + } else { + discountAmount = offer.value; + } + // Apply discount (also ensuring total doesn't go below 0) + totalAmount = Math.max(0, totalAmount - discountAmount); + } } } + + // let totalAmount = 0; + + // if (Array.isArray(services) && services.length > 0) { + // for (const item of services) { + // const service = await Service.findOne({ + // _id: item.serviceId, + // isActive: true, + // }); + // if (!service) throw new Error(`Service not available: ${item.serviceId}`); + + // totalAmount += + // item.variant === "male" ? service.prices.male : service.prices.female; + // } + // } + + // // Products + // if (products && products.length > 0) { + // for (const prodId of products) { + // const product = await Product.findOne({ _id: prodId, isActive: true }); + // if (!product) throw new Error(`Product not available: ${prodId}`); + // totalAmount += product.price; + + // await Product.findByIdAndUpdate( + // prodId, + // { $inc: { stock: -1 } }, + // { session }, + // ); + // } + // } + const appointment = await Appointment.create( [ { @@ -188,7 +261,7 @@ export const createBooking = async (req, res) => { services, products, staff: staffId, - totalAmount, + totalAmount: Math.round(totalAmount), // Round to avoid decimals status: "confirmed", }, ], diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 4d23dd7..c001592 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -5,6 +5,12 @@ const userSchema = new mongoose.Schema({ email: { type: String, unique: true, sparse: true }, // for booking notification and staff/admin login password: { type: String }, // Only for Admin/Staff role: { type: String, enum: ["user", "admin", "staff"], default: "user" }, + subscriptions: [{ + name: { type: String }, // e.g., "Gold Membership", "Gel Polish Yearly" + type: { type: String, enum: ["general_discount", "service_specific"] }, + expiry: { type: Date }, + isActive: { type: Boolean, default: true } + }], name: { type: String }, staffProfile: {type: mongoose.Schema.Types.ObjectId, ref: "Staff"}, createdAt: { type: Date, default: Date.now }, diff --git a/backend/src/routes/publicRoutes.js b/backend/src/routes/publicRoutes.js index 6df59e1..533ca52 100644 --- a/backend/src/routes/publicRoutes.js +++ b/backend/src/routes/publicRoutes.js @@ -62,4 +62,38 @@ router.get("/categories", async (req, res) => { } }); +// 7. Verify Coupon Endpoint +router.post("/verify-coupon", async (req, res) => { + try { + const { code } = req.body; + if (!code) return res.status(400).json({ message: "Code is required" }); + + // Finding active offer matching the code + const offer = await Offer.findOne({ + code: code.toUpperCase(), + isActive: true + }); + + if (!offer) { + return res.status(404).json({ message: "Invalid or inactive coupon code." }); + } + + // Check Expiry + if (offer.expiryDate && new Date(offer.expiryDate) < new Date()) { + return res.status(400).json({ message: "This coupon has expired." }); + } + + // Return offer details needed for calculation + return res.json({ + code: offer.code, + type: offer.type, // "percentage" or "flat" + value: offer.value, + title: offer.title + }); + + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + export default router; diff --git a/frontend/src/components/booking/BookingModal.jsx b/frontend/src/components/booking/BookingModal.jsx index 901cf74..d5b2051 100644 --- a/frontend/src/components/booking/BookingModal.jsx +++ b/frontend/src/components/booking/BookingModal.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import api from "../../../utils/api.js"; import { X, @@ -6,12 +6,31 @@ import { Clock, ChevronLeft, ChevronRight, + ShoppingBag, + AlertCircle, } from "lucide-react"; import { useBooking } from "../../context/BookingContext"; +import { useNavigate } from "react-router-dom"; -const BookingModal = ({ isOpen, onClose, selectedServices }) => { +const BookingModal = ({ + isOpen, + onClose, + selectedServices, + selectedProducts, + isForceLogin, +}) => { const { login, user, fetchActiveBooking } = useBooking(); - const [step, setStep] = useState(1); // 1: Date/Time, 2: Phone/Auth, 3: OTP, 4: Confirm + const navigate = useNavigate(); + + // Steps: + // 0: Cart Summary + // 1: Date/Time + // 2: Login (Phone) + // 3: Verify OTP + // 4: Confirm + // 5: Empty Cart Suggestion + const [step, setStep] = useState(1); + const [selectedDate, setSelectedDate] = useState(null); const [selectedTime, setSelectedTime] = useState(null); const [currentMonth, setCurrentMonth] = useState(new Date()); @@ -23,33 +42,51 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { const [isLoading, setIsLoading] = useState(false); const [availableSlots, setAvailableSlots] = useState([]); + const [coupon, setCoupon] = useState(""); + const [discount, setDiscount] = useState(0); + + const determineInitialStep = useCallback(() => { + if (selectedServices?.length > 0 || selectedProducts?.length > 0) { + setStep(0); // Go to Cart Summary + } else { + setStep(5); // Go to Suggestion + } + }, [selectedServices, selectedProducts]); + - // Reset when opening useEffect(() => { if (isOpen) { - setStep(1); - setSelectedDate(null); - setSelectedTime(null); + if (isForceLogin && !user) { + setStep(2); + } else { + determineInitialStep(); + } + if (!isForceLogin) { + setSelectedDate(null); + setSelectedTime(null); + } setPhone(user?.phone || ""); setName(user?.name || ""); + setCoupon(""); + setDiscount(0); } - }, [isOpen, user]); + }, [isOpen]); + useEffect(() => { const fetchSlots = async () => { - if (!selectedDate ) { + if (!selectedDate) { setAvailableSlots([]); // Clear slots if conditions aren't met return; } setIsLoading(true); try { - // Format Date: YYYY-MM-DD + // Format Date: YYYY-MM-DD const dateStr = selectedDate.toLocaleDateString("en-CA"); - const serviceIds = selectedServices.length > 0 - ? selectedServices.map((s) => s._id || s.id).join(",") - : ""; - console.log(`Fetching slots for: ${dateStr}, Services: ${serviceIds}`); + const serviceIds = + selectedServices.length > 0 ? selectedServices.map((s) => s._id || s.id).join(",") : ""; + // console.log(`Fetching slots for: ${dateStr}, Services: ${serviceIds}`); const res = await api.public.getSlots(dateStr, serviceIds); if (res.data && Array.isArray(res.data.slots)) { @@ -60,17 +97,18 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { } } catch (error) { console.error("Error fetching slots", error); + setAvailableSlots([]); } finally { setIsLoading(false); } }; - fetchSlots(); - }, [selectedDate, selectedServices]); + if (step === 1) fetchSlots(); + }, [selectedDate, selectedServices, step]); if (!isOpen) return null; - // Calendar Logic + // Calendar Logic const getDaysInMonth = (date) => { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); }; @@ -126,20 +164,48 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { const prevMonth = () => { const now = new Date(); - const prev = new Date( - currentMonth.getFullYear(), - currentMonth.getMonth() - 1, - 1, - ); - if ( - prev.getMonth() >= now.getMonth() || - prev.getFullYear() > now.getFullYear() - ) { + const prev = new Date( currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1); + if ( prev.getMonth() >= now.getMonth() || prev.getFullYear() > now.getFullYear()) { setCurrentMonth(prev); } }; - //API Handlers + //CART & TOTALS + const calculateTotal = () => { + const servicesTotal = selectedServices?.reduce((acc, s) => acc + (parseInt(s.price) || 0), 0) || 0; + const productsTotal = selectedProducts?.reduce((acc, p) => acc + (parseInt(p.price) || 0), 0) || 0; + return servicesTotal + productsTotal; + }; + + const handleApplyCoupon = async () => { + if (!coupon.trim()) return; + setIsLoading(true); + try { + const res = await api.public.verifyCoupon(coupon); + const offer = res.data; + + const currentTotal = calculateTotal(); + let disc = 0; + + if (offer.type === "percentage") { + disc = (currentTotal * offer.value) / 100; + } else { + disc = offer.value; + } + + setDiscount(Math.min(disc, currentTotal)); + alert(`Coupon "${offer.code}" Applied! You saved ₹${Math.min(disc, currentTotal)}`); + + } catch (error) { + setDiscount(0); + const msg = error.response?.data?.message || "Invalid Coupon"; + alert(msg); + } finally { + setIsLoading(false); + } + }; + + //API Handlers const handleSendOtp = async () => { if (!name.trim()) return alert("Please enter your name"); if (!phone || phone.length < 10) @@ -160,10 +226,52 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { setIsLoading(true); try { const res = await api.auth.verifyOtp(phone, otp, name); - login({ ...res.data.user, token: res.data.token }); - setStep(4); // Go to Confirm - } catch { - alert("Invalid OTP"); + const userData = { ...res.data.user, token: res.data.token }; + login(userData); + + try { + const bookingRes = await api.bookings.getMyBookings(); + const bookings = Array.isArray(bookingRes.data) ? bookingRes.data : []; + + const now = new Date(); + now.setHours(0, 0, 0, 0); + + const hasActive = bookings.find((b) => { + const bookingDate = new Date(b.date); + const isActiveStatus = b.status !== "cancelled" && b.status !== "completed"; + const isFuture = bookingDate >= now; + return isActiveStatus && isFuture; + }); + + if (hasActive) { + alert("You already have an upcoming visit scheduled. Redirecting to your schedule..."); + fetchActiveBooking(); + onClose(); + navigate("/schedule"); + return; + } + } catch (err) { + console.error("Booking check failed", err); + } + + if (selectedDate && selectedTime) { + // Edge Case: User picked date -> Session Expired -> Re-login + setStep(4); // Go to Confirm + } else { + // Check Cart State + if ( + (selectedServices && selectedServices.length > 0) || + (selectedProducts && selectedProducts.length > 0) + ) { + setStep(0); // Go to Cart Summary + } else { + setStep(5); // Go to Suggestion + } + } + // setStep(4); // Go to Confirm + } catch(error) { + console.error(error); + alert("Invalid OTP or Login Failed"); } finally { setIsLoading(false); } @@ -172,17 +280,21 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { const handleConfirmBooking = async () => { setIsLoading(true); try { - const servicesPayload = selectedServices.length > 0 ? selectedServices.map((s) => ({ - serviceId: s._id || s.id, - variant: "female", // Defaulting to female for now - })) : []; + const servicesPayload = + selectedServices.length > 0 + ? selectedServices.map((s) => ({ + serviceId: s._id || s.id, + variant: "female", // Defaulting to female for now + })) + : []; await api.bookings.create({ userId: user._id, - date: selectedDate.toISOString(), + date: selectedDate.toISOString(), timeSlot: selectedTime, services: servicesPayload, products: [], staffId: null, + couponCode: coupon, }); alert("Booking Confirmed Successfully!"); @@ -201,13 +313,12 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => { {/* Header */}