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 */}

- {step === 1 - ? "Schedule Visit" - : step === 2 - ? "Login" - : step === 3 - ? "Verify" - : "Confirm"} + {step === 0 ? "Review Order" : + step === 1 ? "Select Date" : + step === 2 ? "Login" : + step === 3 ? "Verify OTP" : + step === 4 ? "Confirm Booking" : + step === 5 ? "Start Booking" : ""}

+
+
+ Subtotal + ₹{calculateTotal()} +
+ {discount > 0 && ( +
+ Discount + -₹{discount} +
+ )} +
+ Total + ₹{calculateTotal() - discount} +
+ + + + + )} + + + {/* STEP 5: SUGGESTION (Empty Cart) */} + {step === 5 && ( +
+
+ +
+
+

No Services Selected

+

+ We recommend adding services to your cart for accurate duration planning. +

+
+
+ + +
+
+ )} + + + {/* STEP 1: DATE & TIME */} {step === 1 && (
- {/* Calendar Section */}

Select Date

- + - {currentMonth.toLocaleString("default", { - month: "long", - year: "numeric", - })} + {currentMonth.toLocaleString("default", { month: "long", year: "numeric" })} - +
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( - - {d} - + {d} ))}
{generateCalendar()}
- - {/* Time Section */} + + {/* Time Slots */}
- {availableSlots.length > 0 ? ( - availableSlots.map((time) => ( + {availableSlots.length > 0 ? availableSlots.map((time) => ( - )) - ) : ( -

- No slots available for this date. -

- )} + )) :

No slots available (Select a service or check date)

}
+ +
)} + {/* STEP 2: LOGIN */} {step === 2 && (
- - setName(e.target.value)} - /> - - + + setName(e.target.value)} /> +
- - +91 - - setPhone(e.target.value)} - /> + +91 + setPhone(e.target.value)} />
-
)} + {/* STEP 3: OTP VERIFICATION */} {step === 3 && (

@@ -353,57 +523,32 @@ const BookingModal = ({ isOpen, onClose, selectedServices }) => {

)} + {/* STEP 4: CONFIRMATION */} {step === 4 && (
- {selectedDate?.toLocaleDateString("en-US", { - weekday: "short", - month: "long", - day: "numeric", - })}{" "} - at {selectedTime} + {selectedDate?.toLocaleDateString("en-US", { weekday: "short", month: "long", day: "numeric" })} at {selectedTime}
- {selectedServices.map((s) => ( - - {s.name} - + {selectedServices?.map((s) => ( + {s.name} ))}
+
+ Total Amount + ₹{Math.max(0, calculateTotal() - discount)} +
-
)} - - {/* Footer Actions (Only for Step 1) */} - {step === 1 && ( -
- -
- )} ); -}; +};; export default BookingModal; diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index ae46884..2ec1092 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -6,14 +6,18 @@ import { useBooking } from "../../context/BookingContext"; const Navbar = ({ showLogo = false, onOpenBooking }) => { const [isMenuOpen, setIsMenuOpen] = useState(false); - const { activeBooking } = useBooking(); + const { activeBooking, user } = useBooking(); const navigate = useNavigate(); const handleBookingClick = () => { + if (!user) { + onOpenBooking(true); // Pass true to indicate we want to open booking after login + return; + } if (activeBooking) { navigate("/schedule"); } else { - onOpenBooking(); + onOpenBooking(false); } }; diff --git a/frontend/src/context/BookingContext.jsx b/frontend/src/context/BookingContext.jsx index 4738610..0c2c571 100644 --- a/frontend/src/context/BookingContext.jsx +++ b/frontend/src/context/BookingContext.jsx @@ -9,6 +9,7 @@ export const BookingProvider = ({ children }) => { const [user, setUser] = useState(null); const [cart, setCart] = useState({ services: [], products: [] }); const [activeBooking, setActiveBooking] = useState(null); + const [pendingAction, setPendingAction] = useState(null); // Load user from localStorage on mount useEffect(() => { @@ -37,7 +38,6 @@ export const BookingProvider = ({ children }) => { const active = bookings.find((b) => { const bookingDate = new Date(b.date); const isActiveStatus = b.status !== "cancelled" && b.status !== "completed"; - // Also check if it's in the future or today const isFuture = bookingDate >= now; return isActiveStatus && isFuture; }); @@ -59,12 +59,19 @@ export const BookingProvider = ({ children }) => { const login = (userData) => { setUser(userData); localStorage.setItem("saaga_user", JSON.stringify(userData)); + + if (pendingAction === "openBooking") { + setPendingAction(null); + return true; // Signal that we can proceed with the booking action + } + return false; }; const logout = () => { setUser(null); localStorage.removeItem("saaga_user"); setActiveBooking(null); + setCart({ services: [], products: [] }); }; const addToCart = (item, type = "service") => { @@ -105,6 +112,8 @@ export const BookingProvider = ({ children }) => { clearCart, activeBooking, fetchActiveBooking, + pendingAction, + setPendingAction, }} > {children} diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 8aceeb0..06b1a12 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -46,8 +46,8 @@ const api = { getCategories: () => apiClient.get("/api/public/categories"), getStaff: () => apiClient.get("/api/public/staff"), // Dynamic Slot Fetching - getSlots: (date, serviceIds) => - apiClient.get(`/api/public/slots?date=${date}&serviceIds=${serviceIds}`), + getSlots: (date, serviceIds) => apiClient.get(`/api/public/slots?date=${date}&serviceIds=${serviceIds}`), + verifyCoupon: (code) => apiClient.post("/api/public/verify-coupon", { code }), }, // Auth Routes