From 8437b35f74d758ac7c13bd6b6a53b0c39d6a0d92 Mon Sep 17 00:00:00 2001 From: Anshika Guleria Date: Mon, 17 Aug 2026 11:19:09 +0530 Subject: [PATCH 1/3] feat/added polling to group chats --- backend/.env.example | 9 +- backend/src/controllers/auth.controller.js | 44 ++++ backend/src/controllers/poll.controller.js | 199 +++++++++++++++++ backend/src/index.js | 2 + backend/src/models/poll.model.js | 77 +++++++ backend/src/models/user.model.js | 2 + backend/src/routes/poll.routes.js | 17 ++ backend/src/seeds/makeAdmin.js | 51 +++++ frontend/src/components/ChatContainer.jsx | 87 +++++++- frontend/src/components/MessageInput.jsx | 29 +++ frontend/src/components/PollBubble.jsx | 169 ++++++++++++++ frontend/src/components/PollCreatorModal.jsx | 222 +++++++++++++++++++ frontend/src/pages/SignUpPage.jsx | 9 +- frontend/src/store/useAuthStore.js | 8 + frontend/src/store/usePollStore.js | 103 +++++++++ 15 files changed, 1014 insertions(+), 14 deletions(-) create mode 100644 backend/src/controllers/poll.controller.js create mode 100644 backend/src/models/poll.model.js create mode 100644 backend/src/routes/poll.routes.js create mode 100644 backend/src/seeds/makeAdmin.js create mode 100644 frontend/src/components/PollBubble.jsx create mode 100644 frontend/src/components/PollCreatorModal.jsx create mode 100644 frontend/src/store/usePollStore.js diff --git a/backend/.env.example b/backend/.env.example index e62bcaba..5ae4e271 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,9 +3,12 @@ MONGODB_URI=your_mongodb_connection_string # Server PORT=5001 -NODE_ENV=development - -# Authentication +NODE_ENV=development + +# Set to true only for local testing. Never enable this in production. +BYPASS_EMAIL_VERIFICATION=false + +# Authentication JWT_SECRET=your_jwt_secret REFRESH_TOKEN_SECRET=your_refresh_token_secret diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 6a712e0a..303b0beb 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -89,6 +89,50 @@ export const signup = async (req, res) => { ), })) ); + // Test-only bypass. It requires an explicit opt-in and can never run in + // production, even if the environment flag is set accidentally. + const isEmailVerificationBypassed = + process.env.NODE_ENV === "development" && + process.env.BYPASS_EMAIL_VERIFICATION === "true"; + + if (isEmailVerificationBypassed) { + // Auto-verify the user and log them in immediately + const newUser = await User.create({ + fullName, + email: normalizedEmail, + password: hashedPassword, + securityQuestions: hashedQuestions, + role: "user", + isVerified: true, // skip email verification + }); + + const token = generateToken(newUser._id); + const refreshToken = generateRefreshToken(newUser._id); + const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + newUser.refreshTokenHash = refreshTokenHash; + await newUser.save(); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: false, + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + console.log("[DEV MODE] User auto-verified, no OTP email sent."); + + return res.status(201).json({ + _id: newUser._id, + fullName: newUser.fullName, + email: newUser.email, + profilePic: newUser.profilePic, + role: newUser.role, + token, + message: "[DEV] Account created and verified automatically (dev mode).", + }); + } + // --- END DEV MODE BYPASS --- + const verificationOtp = crypto.randomInt(100000, 1000000).toString(); const hashedVerificationOtp = await bcrypt.hash( diff --git a/backend/src/controllers/poll.controller.js b/backend/src/controllers/poll.controller.js new file mode 100644 index 00000000..be6948bd --- /dev/null +++ b/backend/src/controllers/poll.controller.js @@ -0,0 +1,199 @@ +import mongoose from "mongoose"; +import Group from "../models/group.model.js"; +import Poll from "../models/poll.model.js"; +import { io } from "../lib/socket.js"; + +// Helper — check if user is a member of the group +const isGroupMember = (group, userId) => + group.members.some((m) => m.userId.toString() === userId.toString()); + +// POST /api/polls/groups/:groupId +export const createPoll = async (req, res) => { + try { + const { groupId } = req.params; + const creatorId = req.user._id; + const { question, options, expiresAt } = req.body; + + if (!mongoose.Types.ObjectId.isValid(groupId)) { + return res.status(400).json({ message: "Invalid group ID" }); + } + + const group = await Group.findById(groupId); + if (!group) { + return res.status(404).json({ message: "Group not found" }); + } + + if (!isGroupMember(group, creatorId)) { + return res.status(403).json({ message: "Not a group member" }); + } + + if (!question?.trim()) { + return res.status(400).json({ message: "Poll question is required" }); + } + + if (!Array.isArray(options) || options.length < 2 || options.length > 10) { + return res + .status(400) + .json({ message: "A poll must have between 2 and 10 options" }); + } + + const sanitizedOptions = options + .map((o) => ({ text: typeof o === "string" ? o.trim() : o?.text?.trim() })) + .filter((o) => o.text); + + if (sanitizedOptions.length < 2) { + return res + .status(400) + .json({ message: "At least 2 non-empty options are required" }); + } + + const poll = await Poll.create({ + groupId, + creatorId, + question: question.trim(), + options: sanitizedOptions, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }); + + const populated = await poll.populate("creatorId", "fullName profilePic"); + + // Broadcast to all group members + io.to(groupId.toString()).emit("newGroupPoll", populated); + + res.status(201).json(populated); + } catch (error) { + console.error("Create poll error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + +// GET /api/polls/groups/:groupId +export const getGroupPolls = async (req, res) => { + try { + const { groupId } = req.params; + const userId = req.user._id; + + if (!mongoose.Types.ObjectId.isValid(groupId)) { + return res.status(400).json({ message: "Invalid group ID" }); + } + + const group = await Group.findById(groupId); + if (!group) { + return res.status(404).json({ message: "Group not found" }); + } + + if (!isGroupMember(group, userId)) { + return res.status(403).json({ message: "Access denied" }); + } + + const polls = await Poll.find({ groupId }) + .populate("creatorId", "fullName profilePic") + .sort({ createdAt: -1 }); + + res.status(200).json(polls); + } catch (error) { + console.error("Get group polls error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + +// POST /api/polls/:pollId/vote +export const votePoll = async (req, res) => { + try { + const { pollId } = req.params; + const { optionId } = req.body; + const userId = req.user._id; + + if (!mongoose.Types.ObjectId.isValid(pollId)) { + return res.status(400).json({ message: "Invalid poll ID" }); + } + + const poll = await Poll.findById(pollId); + if (!poll) { + return res.status(404).json({ message: "Poll not found" }); + } + + // Verify membership + const group = await Group.findById(poll.groupId); + if (!group || !isGroupMember(group, userId)) { + return res.status(403).json({ message: "Not a group member" }); + } + + // Check poll is still active (not closed / not expired) + if (poll.isClosed || (poll.expiresAt && new Date() > poll.expiresAt)) { + return res.status(400).json({ message: "This poll is no longer active" }); + } + + const targetOption = poll.options.id(optionId); + if (!targetOption) { + return res.status(400).json({ message: "Option not found" }); + } + + // Remove user's vote from all options first (enforce single-choice) + poll.options.forEach((opt) => { + opt.votes = opt.votes.filter((v) => v.toString() !== userId.toString()); + }); + + // Toggle: if user already had this option, the vote is now removed (removed above). + // If not, add the vote. + const alreadyVoted = targetOption.votes.some( + (v) => v.toString() === userId.toString(), + ); + + if (!alreadyVoted) { + targetOption.votes.push(userId); + } + + await poll.save(); + + const populated = await poll.populate("creatorId", "fullName profilePic"); + + // Broadcast updated poll to the whole group + io.to(poll.groupId.toString()).emit("pollUpdated", populated); + + res.status(200).json(populated); + } catch (error) { + console.error("Vote poll error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + +// PATCH /api/polls/:pollId/close +export const closePoll = async (req, res) => { + try { + const { pollId } = req.params; + const userId = req.user._id; + + if (!mongoose.Types.ObjectId.isValid(pollId)) { + return res.status(400).json({ message: "Invalid poll ID" }); + } + + const poll = await Poll.findById(pollId); + if (!poll) { + return res.status(404).json({ message: "Poll not found" }); + } + + // Only the creator can close the poll + if (poll.creatorId.toString() !== userId.toString()) { + return res + .status(403) + .json({ message: "Only the poll creator can close it" }); + } + + if (poll.isClosed) { + return res.status(400).json({ message: "Poll is already closed" }); + } + + poll.isClosed = true; + await poll.save(); + + const populated = await poll.populate("creatorId", "fullName profilePic"); + + io.to(poll.groupId.toString()).emit("pollUpdated", populated); + + res.status(200).json(populated); + } catch (error) { + console.error("Close poll error:", error); + res.status(500).json({ message: "Server error" }); + } +}; diff --git a/backend/src/index.js b/backend/src/index.js index f8de292c..5d3e05ce 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,6 +15,7 @@ import adminRoutes from "./routes/admin.routes.js"; import reportRoutes from "./routes/report.routes.js"; import gifRoutes from "./routes/gif.routes.js"; import userRoutes from "./routes/user.route.js"; +import pollRoutes from "./routes/poll.routes.js"; dotenv.config(); @@ -48,6 +49,7 @@ app.use("/api/admin", adminRoutes); app.use("/api/reports", reportRoutes); app.use("/api/gif", gifRoutes); app.use("/api/users", userRoutes); +app.use("/api/polls", pollRoutes); if (process.env.NODE_ENV === "production") { app.use(express.static(path.join(__dirname, "../frontend/dist"))); diff --git a/backend/src/models/poll.model.js b/backend/src/models/poll.model.js new file mode 100644 index 00000000..28aae7d5 --- /dev/null +++ b/backend/src/models/poll.model.js @@ -0,0 +1,77 @@ +import mongoose from "mongoose"; + +const pollOptionSchema = new mongoose.Schema( + { + text: { + type: String, + required: true, + trim: true, + }, + votes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + }, + { _id: true }, +); + +const pollSchema = new mongoose.Schema( + { + groupId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Group", + required: true, + index: true, + }, + + creatorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + question: { + type: String, + required: true, + trim: true, + }, + + options: { + type: [pollOptionSchema], + validate: { + validator: (opts) => opts.length >= 2 && opts.length <= 10, + message: "A poll must have between 2 and 10 options", + }, + }, + + // Optional expiry — null means no expiry + expiresAt: { + type: Date, + default: null, + }, + + isClosed: { + type: Boolean, + default: false, + index: true, + }, + }, + { timestamps: true }, +); + +// Virtual: is this poll currently active? +pollSchema.virtual("isActive").get(function () { + if (this.isClosed) return false; + if (this.expiresAt && new Date() > this.expiresAt) return false; + return true; +}); + +pollSchema.set("toObject", { virtuals: true }); +pollSchema.set("toJSON", { virtuals: true }); + +const Poll = mongoose.models.Poll || mongoose.model("Poll", pollSchema); + +export default Poll; diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 12ea320f..399b9220 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -160,6 +160,8 @@ const userSchema = new mongoose.Schema( googleId: { type: String, default: null, + unique: true, + sparse: true, }, refreshTokenHash: { diff --git a/backend/src/routes/poll.routes.js b/backend/src/routes/poll.routes.js new file mode 100644 index 00000000..561d1787 --- /dev/null +++ b/backend/src/routes/poll.routes.js @@ -0,0 +1,17 @@ +import express from "express"; +import { protectRoute } from "../middleware/auth.middleware.js"; +import { + createPoll, + getGroupPolls, + votePoll, + closePoll, +} from "../controllers/poll.controller.js"; + +const router = express.Router(); + +router.post("/groups/:groupId", protectRoute, createPoll); +router.get("/groups/:groupId", protectRoute, getGroupPolls); +router.post("/:pollId/vote", protectRoute, votePoll); +router.patch("/:pollId/close", protectRoute, closePoll); + +export default router; diff --git a/backend/src/seeds/makeAdmin.js b/backend/src/seeds/makeAdmin.js new file mode 100644 index 00000000..8ee84c88 --- /dev/null +++ b/backend/src/seeds/makeAdmin.js @@ -0,0 +1,51 @@ +/** + * makeAdmin.js — Promote a user to admin role + * + * Usage: + * node src/seeds/makeAdmin.js + * + * Example: + * node src/seeds/makeAdmin.js you@example.com + */ + +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import User from "../models/user.model.js"; + +dotenv.config(); + +const email = process.argv[2]; + +if (!email) { + console.error("❌ Please provide an email address."); + console.error(" Usage: node src/seeds/makeAdmin.js "); + process.exit(1); +} + +async function makeAdmin() { + try { + await mongoose.connect(process.env.MONGODB_URI); + console.log("✅ Connected to MongoDB"); + + const user = await User.findOneAndUpdate( + { email: email.toLowerCase().trim() }, + { $set: { role: "admin" } }, + { new: true } + ); + + if (!user) { + console.error(`❌ No user found with email: ${email}`); + process.exit(1); + } + + console.log(`✅ Success! "${user.fullName}" (${user.email}) is now an admin.`); + } catch (err) { + console.error("❌ Error:", err.message); + process.exit(1); + } finally { + await mongoose.disconnect(); + console.log("🔌 Disconnected from MongoDB"); + } +} + +makeAdmin(); diff --git a/frontend/src/components/ChatContainer.jsx b/frontend/src/components/ChatContainer.jsx index ecdbc795..d0c87dac 100644 --- a/frontend/src/components/ChatContainer.jsx +++ b/frontend/src/components/ChatContainer.jsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useMemo } from "react"; import { useChatStore } from "../store/useChatStore"; import { useAuthStore } from "../store/useAuthStore"; +import { usePollStore } from "../store/usePollStore"; import { motion, AnimatePresence } from "framer-motion"; import ChatHeader from "./ChatHeader"; @@ -8,6 +9,7 @@ import MessageInput from "./MessageInput"; import MessageSkeleton from "./skeletons/MessageSkeleton"; import MessageBubble from "./MessageBubble.jsx"; import PinnedHeader from "./PinnedHeader.jsx"; +import PollBubble from "./PollBubble.jsx"; const ChatContainer = () => { const { @@ -27,6 +29,14 @@ const ChatContainer = () => { highlightedMessageId, } = useChatStore(); + const { + polls, + getGroupPolls, + clearPolls, + subscribeToPollEvents, + unsubscribeFromPollEvents, + } = usePollStore(); + const { authUser } = useAuthStore(); const bottomRef = useRef(null); const messageRefs = useRef({}); @@ -37,12 +47,20 @@ const ChatContainer = () => { const wallpaper = chatWallpapers?.[chatId]; const clearedAt = clearedChats?.[chatId]; - // subscribe / unsubscribe + // subscribe / unsubscribe messages useEffect(() => { subscribeToMessages(); return () => unsubscribeFromMessages(); }, [selectedUser?._id, selectedGroup?._id, selectedChatType]); + // subscribe / unsubscribe poll events (group only) + useEffect(() => { + if (selectedChatType === "group") { + subscribeToPollEvents(); + } + return () => unsubscribeFromPollEvents(); + }, [selectedGroup?._id, selectedChatType]); + // fetch messages useEffect(() => { if (selectedChatType === "private" && selectedUser?._id) { @@ -53,6 +71,15 @@ const ChatContainer = () => { } }, [selectedUser?._id, selectedGroup?._id, selectedChatType]); + // fetch polls (group only); clear when leaving group + useEffect(() => { + if (selectedChatType === "group" && selectedGroup?._id) { + getGroupPolls(selectedGroup._id); + } else { + clearPolls(); + } + }, [selectedGroup?._id, selectedChatType]); + useEffect(() => { if (!highlightedMessageId) return; @@ -70,7 +97,35 @@ const ChatContainer = () => { bottomRef.current?.scrollIntoView({ behavior: window.innerWidth < 640 ? "auto" : "smooth", }); - }, [messages, typingUsers, isAILoading]); + }, [messages, polls, typingUsers, isAILoading]); + + const visibleMessages = clearedAt + ? messages.filter((msg) => new Date(msg.createdAt).getTime() > clearedAt) + : messages; + + // Merge messages and polls into a single sorted timeline + const timelineItems = useMemo(() => { + const msgItems = visibleMessages.map((m) => ({ + type: "message", + _id: m._id, + createdAt: m.createdAt, + data: m, + })); + + const pollItems = + selectedChatType === "group" + ? polls.map((p) => ({ + type: "poll", + _id: p._id, + createdAt: p.createdAt, + data: p, + })) + : []; + + return [...msgItems, ...pollItems].sort( + (a, b) => new Date(a.createdAt) - new Date(b.createdAt), + ); + }, [visibleMessages, polls, selectedChatType]); if (!selectedUser && !selectedGroup) return null; @@ -84,10 +139,6 @@ const ChatContainer = () => { ); } - const visibleMessages = clearedAt - ? messages.filter((msg) => new Date(msg.createdAt).getTime() > clearedAt) - : messages; - return (
@@ -117,14 +168,30 @@ const ChatContainer = () => {
- {visibleMessages.map((message, idx) => { + {timelineItems.map((item, idx) => { + const isLast = idx === timelineItems.length - 1; + + if (item.type === "poll") { + return ( +
{ + if (isLast) bottomRef.current = el; + }} + className="flex justify-start px-2 sm:px-4" + > + +
+ ); + } + + // Regular message + const message = item.data; const isMe = typeof message.senderId === "string" ? message.senderId === authUser._id : message.senderId?._id === authUser._id; - const isLast = idx === visibleMessages.length - 1; - const sender = selectedChatType === "group" ? message.senderId diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx index 1960fd87..934c66fc 100644 --- a/frontend/src/components/MessageInput.jsx +++ b/frontend/src/components/MessageInput.jsx @@ -8,11 +8,15 @@ import { StopCircle, Smile, Clapperboard, + BarChart2, } from "lucide-react"; + import toast from "react-hot-toast"; import { useChatStore } from "../store/useChatStore"; import EmojiPicker from "emoji-picker-react"; import GifPicker from "./GifPicker"; +import PollCreatorModal from "./PollCreatorModal"; + const MessageInput = () => { const [text, setText] = useState(""); @@ -23,6 +27,8 @@ const MessageInput = () => { const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showGifPicker, setShowGifPicker] = useState(false); const [dragActive, setDragActive] = useState(false); + const [showPollModal, setShowPollModal] = useState(false); + const emojiRef = useRef(null); const gifRef = useRef(null); @@ -38,6 +44,7 @@ const MessageInput = () => { sendMessageToAI, selectedChatType, selectedUser, + selectedGroup, startTyping, stopTyping, replyingTo, @@ -45,6 +52,7 @@ const MessageInput = () => { smartReplies, } = useChatStore(); + const isAI = selectedChatType === "private" && selectedUser?.isAI; useEffect(() => { @@ -483,6 +491,19 @@ const MessageInput = () => { > + + {/* Poll button — group chats only */} + {selectedChatType === "group" && ( + + )} +
)}
@@ -524,6 +545,13 @@ const MessageInput = () => {
+ + {/* Poll creator modal — rendered outside form to avoid nesting issues */} + setShowPollModal(false)} + groupId={selectedGroup?._id} + />
); }; @@ -541,3 +569,4 @@ const Preview = ({ children, onRemove }) => ( ); export default MessageInput; + diff --git a/frontend/src/components/PollBubble.jsx b/frontend/src/components/PollBubble.jsx new file mode 100644 index 00000000..f9a2d2e9 --- /dev/null +++ b/frontend/src/components/PollBubble.jsx @@ -0,0 +1,169 @@ +import { motion } from "framer-motion"; +import { BarChart2, Clock, CheckCircle2, Lock } from "lucide-react"; +import { useAuthStore } from "../store/useAuthStore"; +import { usePollStore } from "../store/usePollStore"; +import { formatMessageTime } from "../lib/utils"; + +const PollBubble = ({ poll }) => { + const { authUser } = useAuthStore(); + const { votePoll, closePoll } = usePollStore(); + + const isCreator = poll.creatorId?._id === authUser._id || poll.creatorId === authUser._id; + const isExpired = poll.expiresAt && new Date() > new Date(poll.expiresAt); + const isActive = !poll.isClosed && !isExpired; + + // Total votes across all options + const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0); + + // Which option did the current user vote for? + const myVotedOptionId = poll.options.find((opt) => + opt.votes.some((v) => (typeof v === "string" ? v : v?._id || v?.toString?.()) === authUser._id), + )?._id; + + const handleVote = (optionId) => { + if (!isActive) return; + votePoll(poll._id, optionId); + }; + + const handleClose = () => { + if (!isCreator) return; + if (window.confirm("Close this poll? Members will no longer be able to vote.")) { + closePoll(poll._id); + } + }; + + const getVotePercent = (votes) => { + if (totalVotes === 0) return 0; + return Math.round((votes.length / totalVotes) * 100); + }; + + return ( + + {/* Header */} +
+
+ +
+
+

+ {poll.question} +

+

+ By {poll.creatorId?.fullName || "Unknown"} +

+
+
+ + {/* Status badge */} + {(!isActive) && ( +
+ + {poll.isClosed ? "Closed" : "Expired"} +
+ )} + + {/* Options */} +
+ {poll.options.map((opt) => { + const percent = getVotePercent(opt.votes); + const isMyVote = myVotedOptionId === opt._id; + const isLeading = + totalVotes > 0 && + opt.votes.length === Math.max(...poll.options.map((o) => o.votes.length)); + + return ( + + ); + })} +
+ + {/* Footer */} +
+
+ {totalVotes} vote{totalVotes !== 1 ? "s" : ""} + {poll.expiresAt && isActive && ( + <> + · + + + Ends {new Date(poll.expiresAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} +
+ +
+ + {formatMessageTime(poll.createdAt)} + + {isCreator && isActive && ( + + )} +
+
+
+ ); +}; + +export default PollBubble; diff --git a/frontend/src/components/PollCreatorModal.jsx b/frontend/src/components/PollCreatorModal.jsx new file mode 100644 index 00000000..721ed8df --- /dev/null +++ b/frontend/src/components/PollCreatorModal.jsx @@ -0,0 +1,222 @@ +import { useState } from "react"; +import { X, Plus, Trash2, BarChart2 } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { usePollStore } from "../store/usePollStore"; + +const PollCreatorModal = ({ isOpen, onClose, groupId }) => { + const { createPoll } = usePollStore(); + + const [question, setQuestion] = useState(""); + const [options, setOptions] = useState(["", ""]); + const [expiresAt, setExpiresAt] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleAddOption = () => { + if (options.length >= 10) return; + setOptions([...options, ""]); + }; + + const handleRemoveOption = (index) => { + if (options.length <= 2) return; + setOptions(options.filter((_, i) => i !== index)); + }; + + const handleOptionChange = (index, value) => { + const updated = [...options]; + updated[index] = value; + setOptions(updated); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const filledOptions = options.map((o) => o.trim()).filter(Boolean); + if (!question.trim()) return; + if (filledOptions.length < 2) return; + + setIsSubmitting(true); + const result = await createPoll(groupId, { + question: question.trim(), + options: filledOptions, + expiresAt: expiresAt || null, + }); + setIsSubmitting(false); + + if (result) { + handleClose(); + } + }; + + const handleClose = () => { + setQuestion(""); + setOptions(["", ""]); + setExpiresAt(""); + onClose(); + }; + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Modal */} + e.stopPropagation()} + > +
+ {/* Header */} +
+
+
+ +
+

Create a Poll

+
+ +
+ + {/* Form */} +
+ {/* Question */} +
+ + setQuestion(e.target.value)} + placeholder="Ask a question…" + maxLength={200} + className="input input-bordered w-full text-sm focus:input-primary" + required + /> +
+ + {/* Options */} +
+ + +
+ + {options.map((opt, idx) => ( + + + {idx + 1} + + + handleOptionChange(idx, e.target.value) + } + placeholder={`Option ${idx + 1}`} + maxLength={100} + className="input input-bordered input-sm flex-1 text-sm focus:input-primary" + /> + + + ))} + +
+ + {options.length < 10 && ( + + )} +
+ + {/* Optional expiry */} +
+ + setExpiresAt(e.target.value)} + min={new Date().toISOString().slice(0, 16)} + className="input input-bordered input-sm w-full text-sm focus:input-primary" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ + )} +
+ ); +}; + +export default PollCreatorModal; diff --git a/frontend/src/pages/SignUpPage.jsx b/frontend/src/pages/SignUpPage.jsx index a559ccc7..63a56945 100644 --- a/frontend/src/pages/SignUpPage.jsx +++ b/frontend/src/pages/SignUpPage.jsx @@ -58,6 +58,13 @@ const SignUpPage = () => { if (!validateForm()) return; const result = await signup(formData); if (result) { + // A development OTP bypass returns an authenticated session directly. + // Only show the verification screen when the backend requests it. + if (result.token) { + navigate("/"); + return; + } + const email = result.email || formData.email.trim(); sessionStorage.setItem("pendingVerificationEmail", email); navigate("/verify-email", { state: { email } }); @@ -238,4 +245,4 @@ const SignUpPage = () => { ); }; -export default SignUpPage; \ No newline at end of file +export default SignUpPage; diff --git a/frontend/src/store/useAuthStore.js b/frontend/src/store/useAuthStore.js index e2d07404..747e1558 100644 --- a/frontend/src/store/useAuthStore.js +++ b/frontend/src/store/useAuthStore.js @@ -46,6 +46,14 @@ export const useAuthStore = create((set, get) => ({ set({ isSigningUp: true }); try { const res = await axiosInstance.post("/auth/signup", data); + // Development signup can return an authenticated user when email + // verification is deliberately bypassed. Persist that session just as + // the regular login flow does. + if (res.data.token) { + localStorage.setItem("token", res.data.token); + set({ authUser: res.data }); + get().connectSocket(); + } toast.success(res.data.message || "Account created. Please verify your Email."); return res.data; } catch (error) { diff --git a/frontend/src/store/usePollStore.js b/frontend/src/store/usePollStore.js new file mode 100644 index 00000000..6a24cd08 --- /dev/null +++ b/frontend/src/store/usePollStore.js @@ -0,0 +1,103 @@ +import { create } from "zustand"; +import toast from "react-hot-toast"; +import { axiosInstance } from "../lib/axios"; +import { useAuthStore } from "./useAuthStore"; + +export const usePollStore = create((set, get) => ({ + polls: [], + isPollsLoading: false, + + // ─── Fetch ───────────────────────────────────────────────────────────────── + + getGroupPolls: async (groupId) => { + set({ isPollsLoading: true }); + try { + const res = await axiosInstance.get(`/polls/groups/${groupId}`); + set({ polls: res.data }); + } catch { + toast.error("Failed to load polls"); + } finally { + set({ isPollsLoading: false }); + } + }, + + // ─── Create ──────────────────────────────────────────────────────────────── + + createPoll: async (groupId, pollData) => { + try { + const res = await axiosInstance.post(`/polls/groups/${groupId}`, pollData); + // Socket will broadcast newGroupPoll — add only if not already present + set((state) => { + const exists = state.polls.some((p) => p._id === res.data._id); + return exists ? {} : { polls: [res.data, ...state.polls] }; + }); + toast.success("Poll created!"); + return res.data; + } catch (err) { + toast.error(err.response?.data?.message || "Failed to create poll"); + return null; + } + }, + + // ─── Vote ────────────────────────────────────────────────────────────────── + + votePoll: async (pollId, optionId) => { + try { + const res = await axiosInstance.post(`/polls/${pollId}/vote`, { + optionId, + }); + // Socket will broadcast pollUpdated — update locally as well for instant feedback + set((state) => ({ + polls: state.polls.map((p) => (p._id === pollId ? res.data : p)), + })); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to vote"); + } + }, + + // ─── Close ───────────────────────────────────────────────────────────────── + + closePoll: async (pollId) => { + try { + const res = await axiosInstance.patch(`/polls/${pollId}/close`); + set((state) => ({ + polls: state.polls.map((p) => (p._id === pollId ? res.data : p)), + })); + toast.success("Poll closed"); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to close poll"); + } + }, + + // ─── Socket subscriptions ────────────────────────────────────────────────── + + subscribeToPollEvents: () => { + const socket = useAuthStore.getState().socket; + if (!socket) return; + + socket.off("newGroupPoll"); + socket.off("pollUpdated"); + + socket.on("newGroupPoll", (poll) => { + set((state) => { + const exists = state.polls.some((p) => p._id === poll._id); + return exists ? {} : { polls: [poll, ...state.polls] }; + }); + }); + + socket.on("pollUpdated", (poll) => { + set((state) => ({ + polls: state.polls.map((p) => (p._id === poll._id ? poll : p)), + })); + }); + }, + + unsubscribeFromPollEvents: () => { + const socket = useAuthStore.getState().socket; + if (!socket) return; + socket.off("newGroupPoll"); + socket.off("pollUpdated"); + }, + + clearPolls: () => set({ polls: [] }), +})); From ce4cb72445e9f6f24a92530a7d0a3e6a4dcfd2f1 Mon Sep 17 00:00:00 2001 From: Anshika Guleria Date: Tue, 18 Aug 2026 21:19:04 +0530 Subject: [PATCH 2/3] fix --- backend/src/models/user.model.js | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 399b9220..d4a315ed 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -159,7 +159,6 @@ const userSchema = new mongoose.Schema( googleId: { type: String, - default: null, unique: true, sparse: true, }, From b018eb8ca1187e86fddcccfa5086c5e631035004 Mon Sep 17 00:00:00 2001 From: Akash Santra Date: Wed, 19 Aug 2026 10:48:05 +0530 Subject: [PATCH 3/3] style: improve poll UI --- frontend/src/components/PollBubble.jsx | 222 +++++++++------ frontend/src/components/PollCreatorModal.jsx | 281 +++++++++++-------- 2 files changed, 302 insertions(+), 201 deletions(-) diff --git a/frontend/src/components/PollBubble.jsx b/frontend/src/components/PollBubble.jsx index f9a2d2e9..9fce6ba8 100644 --- a/frontend/src/components/PollBubble.jsx +++ b/frontend/src/components/PollBubble.jsx @@ -1,5 +1,14 @@ -import { motion } from "framer-motion"; -import { BarChart2, Clock, CheckCircle2, Lock } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + BarChart3, + Clock, + Check, + Lock, + Crown, + Sparkles, + XCircle, + Users +} from "lucide-react"; import { useAuthStore } from "../store/useAuthStore"; import { usePollStore } from "../store/usePollStore"; import { formatMessageTime } from "../lib/utils"; @@ -15,6 +24,9 @@ const PollBubble = ({ poll }) => { // Total votes across all options const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0); + // Highest vote count to determine the leader + const maxVotes = Math.max(...poll.options.map((o) => o.votes.length), 0); + // Which option did the current user vote for? const myVotedOptionId = poll.options.find((opt) => opt.votes.some((v) => (typeof v === "string" ? v : v?._id || v?.toString?.()) === authUser._id), @@ -39,102 +51,161 @@ const PollBubble = ({ poll }) => { return ( + {/* Subtle Ambient Top Accent Glow */} +
+ {/* Header */} -
-
- -
-
-

- {poll.question} -

-

- By {poll.creatorId?.fullName || "Unknown"} -

-
-
+
+
+ {/* Tag & Status */} +
+ + + Poll + - {/* Status badge */} - {(!isActive) && ( -
- - {poll.isClosed ? "Closed" : "Expired"} + {/* Status indicator */} + {!isActive ? ( + + + {poll.isClosed ? "Closed" : "Expired"} + + ) : ( + + + Live + + )} +
+ + {/* Close Action for Creator */} + {isCreator && isActive && ( + + )}
- )} - {/* Options */} -
+ {/* Question Title */} +

+ {poll.question} +

+ +

+ Created by + {poll.creatorId?.fullName || "Anonymous"} +

+
+ + {/* Options List */} +
{poll.options.map((opt) => { const percent = getVotePercent(opt.votes); const isMyVote = myVotedOptionId === opt._id; - const isLeading = - totalVotes > 0 && - opt.votes.length === Math.max(...poll.options.map((o) => o.votes.length)); + const isLeading = totalVotes > 0 && opt.votes.length === maxVotes && maxVotes > 0; return ( - + ); })}
- {/* Footer */} -
-
- {totalVotes} vote{totalVotes !== 1 ? "s" : ""} + {/* Footer Meta */} +
+
+ + {totalVotes} {totalVotes === 1 ? "vote" : "votes"} + {poll.expiresAt && isActive && ( <> - · - + + Ends {new Date(poll.expiresAt).toLocaleDateString(undefined, { month: "short", @@ -147,23 +218,12 @@ const PollBubble = ({ poll }) => { )}
-
- - {formatMessageTime(poll.createdAt)} - - {isCreator && isActive && ( - - )} -
+ + {formatMessageTime(poll.createdAt)} +
); }; -export default PollBubble; +export default PollBubble; \ No newline at end of file diff --git a/frontend/src/components/PollCreatorModal.jsx b/frontend/src/components/PollCreatorModal.jsx index 721ed8df..c4cf40fe 100644 --- a/frontend/src/components/PollCreatorModal.jsx +++ b/frontend/src/components/PollCreatorModal.jsx @@ -1,5 +1,15 @@ import { useState } from "react"; -import { X, Plus, Trash2, BarChart2 } from "lucide-react"; +import { createPortal } from "react-dom"; +import { + X, + Plus, + Trash2, + BarChart3, + Calendar, + Sparkles, + HelpCircle, + Layers +} from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { usePollStore } from "../store/usePollStore"; @@ -27,12 +37,12 @@ const PollCreatorModal = ({ isOpen, onClose, groupId }) => { setOptions(updated); }; + const filledOptions = options.map((o) => o.trim()).filter(Boolean); + const isValid = question.trim().length > 0 && filledOptions.length >= 2; + const handleSubmit = async (e) => { e.preventDefault(); - - const filledOptions = options.map((o) => o.trim()).filter(Boolean); - if (!question.trim()) return; - if (filledOptions.length < 2) return; + if (!isValid || isSubmitting) return; setIsSubmitting(true); const result = await createPoll(groupId, { @@ -54,169 +64,200 @@ const PollCreatorModal = ({ isOpen, onClose, groupId }) => { onClose(); }; - return ( + // Mount directly to document body to completely cover navbar and sidebars + return createPortal( {isOpen && ( - <> - {/* Backdrop */} +
+ {/* Full Screen Backdrop (covers entire app) */} - {/* Modal */} + {/* Modal Card */} e.stopPropagation()} > -
- {/* Header */} -
-
-
- -
-

Create a Poll

+ {/* Top Accent Gradient Bar */} +
+ + {/* Header */} +
+
+
+ +
+
+

+ Create a Poll +

+

+ Ask a question and let your group decide +

-
+ + +
- {/* Form */} -
- {/* Question */} -
- - setQuestion(e.target.value)} - placeholder="Ask a question…" - maxLength={200} - className="input input-bordered w-full text-sm focus:input-primary" - required - /> + {/* Form */} + + {/* Question */} +
+
+ + Question + + {question.length}/200 +
+