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..d4a315ed 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -159,7 +159,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..9fce6ba8 --- /dev/null +++ b/frontend/src/components/PollBubble.jsx @@ -0,0 +1,229 @@ +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"; + +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); + + // 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), + )?._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 ( + + {/* Subtle Ambient Top Accent Glow */} +
+ + {/* Header */} +
+
+ {/* Tag & Status */} +
+ + + Poll + + + {/* Status indicator */} + {!isActive ? ( + + + {poll.isClosed ? "Closed" : "Expired"} + + ) : ( + + + Live + + )} +
+ + {/* Close Action for Creator */} + {isCreator && isActive && ( + + )} +
+ + {/* 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 === maxVotes && maxVotes > 0; + + return ( + handleVote(opt._id)} + disabled={!isActive} + whileTap={isActive ? { scale: 0.98 } : {}} + className={`group relative w-full text-left rounded-2xl overflow-hidden p-3 transition-all duration-200 select-none + ${isActive ? "cursor-pointer hover:border-primary/40" : "cursor-default"} + ${isMyVote + ? "border-2 border-primary bg-primary/5 shadow-sm shadow-primary/10" + : "border border-base-200 dark:border-base-800 bg-base-200/30 hover:bg-base-200/50" + } + `} + > + {/* Animated Progress Fill */} + + + {/* Inner Content */} +
+
+ {/* Selection Radio / Check Indicator */} +
+ {isMyVote && ( + + + + )} +
+ + + {opt.text} + + + {/* Leader Trophy Icon */} + {isLeading && ( + + )} +
+ + {/* Percentage & Vote Count */} +
+ + {percent}% + + + ({opt.votes.length}) + +
+
+
+ ); + })} +
+ + {/* Footer Meta */} +
+
+ + {totalVotes} {totalVotes === 1 ? "vote" : "votes"} + + {poll.expiresAt && isActive && ( + <> + + + + Ends {new Date(poll.expiresAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} +
+ + + {formatMessageTime(poll.createdAt)} + +
+ + ); +}; + +export default PollBubble; \ No newline at end of file diff --git a/frontend/src/components/PollCreatorModal.jsx b/frontend/src/components/PollCreatorModal.jsx new file mode 100644 index 00000000..c4cf40fe --- /dev/null +++ b/frontend/src/components/PollCreatorModal.jsx @@ -0,0 +1,263 @@ +import { useState } from "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"; + +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 filledOptions = options.map((o) => o.trim()).filter(Boolean); + const isValid = question.trim().length > 0 && filledOptions.length >= 2; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!isValid || isSubmitting) 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(); + }; + + // Mount directly to document body to completely cover navbar and sidebars + return createPortal( + + {isOpen && ( +
+ {/* Full Screen Backdrop (covers entire app) */} + + + {/* Modal Card */} + e.stopPropagation()} + > + {/* Top Accent Gradient Bar */} +
+ + {/* Header */} +
+
+
+ +
+
+

+ Create a Poll +

+

+ Ask a question and let your group decide +

+
+
+ + +
+ + {/* Form */} +
+ {/* Question */} +
+
+ + Question + + {question.length}/200 +
+