-
-
+
{isLoading ? (
-
+
Generating QR Code...
) : error ? (
@@ -117,21 +93,23 @@ const QRCodeModal = ({ onClose, eventId, onAttendanceMarked }) => {
-
+
- Show this QR code to event organizers for attendance verification.
+ Show this QR code to event organizers at the check-in desk.
+
+
{expiresHint}
+
+ After you are checked in, this code cannot be used again.
-
- This attendance QR code can be used only once. Do not share it with others.
) : (
@@ -145,4 +123,4 @@ const QRCodeModal = ({ onClose, eventId, onAttendanceMarked }) => {
);
};
-export default QRCodeModal;
\ No newline at end of file
+export default QRCodeModal;
diff --git a/src/features/Modals/Event/QRCodeModal/styles/QRCodeModal.module.scss b/src/features/Modals/Event/QRCodeModal/styles/QRCodeModal.module.scss
index 5c5f8d5..3d0dfbc 100644
--- a/src/features/Modals/Event/QRCodeModal/styles/QRCodeModal.module.scss
+++ b/src/features/Modals/Event/QRCodeModal/styles/QRCodeModal.module.scss
@@ -181,6 +181,14 @@
text-align: center;
}
+.instructionMuted {
+ margin: 0.35rem 0 0;
+ font-size: 0.78rem;
+ color: rgba(255, 255, 255, 0.5);
+ line-height: 1.4;
+ text-align: center;
+}
+
.noCodeContainer {
display: flex;
flex-direction: column;
diff --git a/src/features/Modals/Profile/Admin/PreviewForm.jsx b/src/features/Modals/Profile/Admin/PreviewForm.jsx
index 6381a75..2fd7fef 100644
--- a/src/features/Modals/Profile/Admin/PreviewForm.jsx
+++ b/src/features/Modals/Profile/Admin/PreviewForm.jsx
@@ -19,6 +19,10 @@ import {
import { RecoveryContext } from "../../../../context/RecoveryContext";
import Link from "next/link";
import { useRouter } from "next/navigation";
+import {
+ batchRegistrationErrorMessage,
+ isBatchRegistrationBlocked,
+} from "../../../../utils/batchRestriction";
const operators = [
{ label: "match", value: "===" },
@@ -409,6 +413,16 @@ const PreviewForm = ({
return;
}
+ if (isBatchRegistrationBlocked(authCtx.user?.email)) {
+ Alert({
+ type: "info",
+ message: batchRegistrationErrorMessage(),
+ position: "bottom-right",
+ duration: 4000,
+ });
+ return;
+ }
+
const formData = new FormData();
const mediaFields = filterMediaFields() || [];
const isCreateTeam = data.some(
diff --git a/src/layouts/Blog/LeftSidebar/styles/LeftSidebar.module.scss b/src/layouts/Blog/LeftSidebar/styles/LeftSidebar.module.scss
index 220241b..9020551 100644
--- a/src/layouts/Blog/LeftSidebar/styles/LeftSidebar.module.scss
+++ b/src/layouts/Blog/LeftSidebar/styles/LeftSidebar.module.scss
@@ -102,9 +102,6 @@
color: white;
transition: all 0.3s ease;
- &:focus-within {
- box-shadow: 0 0 10px rgba(244, 43, 3, 0.4);
- }
}
:global(.select-control) {
@@ -154,14 +151,6 @@
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: all 0.3s ease;
-
- &:hover {
- transform: scale(1.1);
- }
-
- &:active {
- transform: scale(0.95);
- }
}
.leftSidebar.collapsed {
diff --git a/src/layouts/Blog/RightSidebar/styles/RightSidebar.module.scss b/src/layouts/Blog/RightSidebar/styles/RightSidebar.module.scss
index 2a1924d..d69f27a 100644
--- a/src/layouts/Blog/RightSidebar/styles/RightSidebar.module.scss
+++ b/src/layouts/Blog/RightSidebar/styles/RightSidebar.module.scss
@@ -53,7 +53,6 @@
background: rgba(255, 190, 11, 0.15);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
- border: 1px solid rgba(255, 190, 11, 0.3);
}
&:last-child {
diff --git a/src/microInteraction/Alert/Alert.tsx b/src/microInteraction/Alert/Alert.tsx
index 5560fb6..d86b666 100644
--- a/src/microInteraction/Alert/Alert.tsx
+++ b/src/microInteraction/Alert/Alert.tsx
@@ -5,12 +5,9 @@ import { Toaster, toast } from "react-hot-toast";
/**
* Toast helper — ported from FED-Frontend/src/microInteraction/Alert/Alert.jsx.
*
- * Kept callable both as a component (`
`) and as a
- * plain function (`Alert({ type, message })`), because the original codebase
- * used both forms — App.jsx calls it directly and discards the returned JSX,
- * relying on `notify()` running as a side effect.
- *
- * Colours and borders are byte-for-byte the originals.
+ * `showAlert()` fires a toast. `
` must be mounted once (root
+ * layout) so notifications render on every route — AttendancePage previously
+ * called `Alert()` without mounting a Toaster, so scans looked silent.
*/
export type AlertProps = {
@@ -19,96 +16,120 @@ export type AlertProps = {
position?: string;
duration?: number;
style?: React.CSSProperties;
+ /** Stable id — replaces an existing toast instead of stacking duplicates. */
+ id?: string;
};
-const Alert = ({ type, message, position, duration, style }: AlertProps) => {
- const notify = () => {
- const defaultStyle: React.CSSProperties = {
- borderRadius: "5px",
- padding: "10px",
- boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
- fontSize: "16px",
- };
+function toastStyle(
+ type: string | undefined,
+ style?: React.CSSProperties,
+): React.CSSProperties {
+ const defaultStyle: React.CSSProperties = {
+ borderRadius: "8px",
+ padding: "12px 14px",
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.18)",
+ fontSize: "15px",
+ maxWidth: "min(420px, calc(100vw - 2rem))",
+ };
+
+ const mobileStyle: React.CSSProperties =
+ typeof window !== "undefined" && window.innerWidth <= 768
+ ? { marginBottom: "2rem" }
+ : {};
- const mobileStyle: React.CSSProperties =
- typeof window !== "undefined" && window.innerWidth <= 768
- ? { marginBottom: "2rem" }
- : {};
+ const base = { ...defaultStyle, ...style, ...mobileStyle };
+
+ switch (type) {
+ case "success":
+ return {
+ ...base,
+ border: "1.5px solid #198754",
+ backgroundColor: "#d3f9d3",
+ color: "#198754",
+ };
+ case "error":
+ return {
+ ...base,
+ border: "1.5px solid #dc3545",
+ backgroundColor: "#FADADD",
+ color: "#b02a37",
+ };
+ case "info":
+ case "warning":
+ return {
+ ...base,
+ border: "1.5px solid #fd7e14",
+ backgroundColor: "#fff3cd",
+ color: "#856404",
+ };
+ case "infoOmega":
+ return {
+ ...base,
+ border: "1.5px solid #0171e3d6",
+ backgroundColor: "white",
+ color: "#0171e3d6",
+ };
+ default:
+ return base;
+ }
+}
- const options = {
- duration: duration || 5000,
- style: { ...defaultStyle, ...style, ...mobileStyle },
- position: (position || "top-right") as never,
- };
+export function showAlert({
+ type,
+ message,
+ position = "top-right",
+ duration = 5000,
+ style,
+ id,
+}: AlertProps) {
+ if (!message) return;
- switch (type) {
- case "success":
- toast.success(message!, {
- ...options,
- style: {
- ...defaultStyle,
- ...style,
- ...mobileStyle,
- border: "1.5px solid green",
- backgroundColor: "#d3f9d3",
- color: "#198754",
- },
- });
- break;
- case "error":
- toast.error(message!, {
- ...options,
- style: {
- ...defaultStyle,
- ...style,
- ...mobileStyle,
- border: "1.5px solid red",
- backgroundColor: "#FADADD",
- color: "red",
- },
- });
- break;
- case "info":
- case "warning":
- toast(message!, {
- ...options,
- style: {
- ...defaultStyle,
- ...style,
- ...mobileStyle,
- border: "1.5px solid orange",
- backgroundColor: "#fff3cd",
- color: "#856404",
- },
- });
- break;
- case "infoOmega":
- toast(message!, {
- ...options,
- style: {
- ...defaultStyle,
- ...style,
- ...mobileStyle,
- border: "1.5px solid #0171e3d6",
- backgroundColor: "white",
- color: "#0171e3d6",
- },
- });
- break;
- default:
- toast(message!, {
- ...options,
- style: { ...defaultStyle, ...style, ...mobileStyle },
- });
- break;
- }
+ const options = {
+ id: id ?? message.slice(0, 80),
+ duration,
+ position: position as never,
+ style: toastStyle(type, style),
};
- if (message) {
- notify();
+ switch (type) {
+ case "success":
+ toast.success(message, options);
+ break;
+ case "error":
+ toast.error(message, options);
+ break;
+ default:
+ toast(message, options);
+ break;
}
+}
- return
;
+/** Mount once in the root layout. */
+export function AlertToaster() {
+ return (
+
+ );
+}
+
+/**
+ * Backward compatible: callable as `Alert({ type, message })` or rendered as
+ * `
` on pages that still mount their own copy (harmless duplicate
+ * until removed — global AlertToaster in layout is authoritative).
+ */
+const Alert = (props: AlertProps) => {
+ if (props.message) {
+ showAlert(props);
+ }
+ return
;
};
export default Alert;
diff --git a/src/microInteraction/index.ts b/src/microInteraction/index.ts
index bceed50..87ebdf1 100644
--- a/src/microInteraction/index.ts
+++ b/src/microInteraction/index.ts
@@ -3,4 +3,4 @@
export { default as Loading } from "./Load/Load";
export { default as MicroLoading } from "./Load/MicroLoad";
export { default as ComponentLoading } from "./Load/ComponentLoad";
-export { default as Alert } from "./Alert/Alert";
+export { default as Alert, showAlert, AlertToaster } from "./Alert/Alert";
diff --git a/src/sections/Home/Events/styles/EventsSection.module.scss b/src/sections/Home/Events/styles/EventsSection.module.scss
index f18393a..47aa279 100644
--- a/src/sections/Home/Events/styles/EventsSection.module.scss
+++ b/src/sections/Home/Events/styles/EventsSection.module.scss
@@ -71,21 +71,15 @@
flex-direction: column;
height: 100%;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
- transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s ease, background 0.35s ease, box-shadow 0.35s ease;
+ transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), background 0.35s ease, box-shadow 0.35s ease;
&:hover {
transform: translateY(-4px);
- border-color: rgba(255, 85, 0, 0.35);
background: rgba(26, 26, 26, 0.78);
- box-shadow:
- 0 0 18px rgba(255, 85, 0, 0.16),
+ box-shadow:
0 16px 40px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
- .cardImage {
- transform: scale(1.04);
- }
-
.eventTitle {
color: #ff8a00;
}
@@ -191,7 +185,6 @@
&:hover {
background: rgba(249, 115, 22, 0.15);
- border-color: rgba(249, 115, 22, 0.4);
transform: translateY(-2px);
}
}
diff --git a/src/sections/Home/Feedback/Feedback.jsx b/src/sections/Home/Feedback/Feedback.jsx
index 123913c..76da192 100644
--- a/src/sections/Home/Feedback/Feedback.jsx
+++ b/src/sections/Home/Feedback/Feedback.jsx
@@ -64,72 +64,6 @@ const Feedback = () => {
};
}, []);
- useEffect(() => {
- const container = containerRef.current;
- const track = feedbacksRef.current;
- if (!container || !track) return undefined;
-
- const handleMouseMove = (e) => {
- const containerRect = container.getBoundingClientRect();
- const containerX = e.clientX - containerRect.left;
- const containerY = e.clientY - containerRect.top;
- container.style.setProperty("--grid-mouse-x", `${containerX}px`);
- container.style.setProperty("--grid-mouse-y", `${containerY}px`);
- container.style.setProperty("--grid-spotlight-opacity", "1");
-
- const cards = track.children;
- const maxDist = 380;
-
- for (let i = 0; i < cards.length; i++) {
- const card = cards[i];
- if (!card) continue;
- const rect = card.getBoundingClientRect();
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
-
- const dist = Math.hypot(e.clientX - centerX, e.clientY - centerY);
-
- const localX = e.clientX - rect.left;
- const localY = e.clientY - rect.top;
-
- card.style.setProperty("--mouse-x", `${localX}px`);
- card.style.setProperty("--mouse-y", `${localY}px`);
-
- if (dist < maxDist) {
- const intensity = Math.pow((maxDist - dist) / maxDist, 1.2);
- card.style.setProperty("--spotlight-opacity", intensity.toFixed(3));
- card.style.borderColor = `rgba(255, 138, 0, ${(0.15 + intensity * 0.55).toFixed(2)})`;
- card.style.boxShadow = `0 0 ${Math.round(intensity * 24)}px rgba(255, 138, 0, ${(intensity * 0.35).toFixed(2)})`;
- } else {
- card.style.setProperty("--spotlight-opacity", "0");
- card.style.borderColor = "";
- card.style.boxShadow = "";
- }
- }
- };
-
- const handleMouseLeave = () => {
- container.style.setProperty("--grid-spotlight-opacity", "0");
-
- const cards = track.children;
- for (let i = 0; i < cards.length; i++) {
- const card = cards[i];
- if (!card) continue;
- card.style.setProperty("--spotlight-opacity", "0");
- card.style.borderColor = "";
- card.style.boxShadow = "";
- }
- };
-
- container.addEventListener("mousemove", handleMouseMove);
- container.addEventListener("mouseleave", handleMouseLeave);
-
- return () => {
- container.removeEventListener("mousemove", handleMouseMove);
- container.removeEventListener("mouseleave", handleMouseLeave);
- };
- }, []);
-
return (
{
- const main = mainRef.current;
- if (!main) return undefined;
-
- const handleMouseMove = (e) => {
- const rect = main.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
- main.style.setProperty("--grid-mouse-x", `${x}px`);
- main.style.setProperty("--grid-mouse-y", `${y}px`);
- main.style.setProperty("--grid-spotlight-opacity", "1");
- };
-
- const handleMouseLeave = () => {
- main.style.setProperty("--grid-spotlight-opacity", "0");
- };
-
- main.addEventListener("mousemove", handleMouseMove);
- main.addEventListener("mouseleave", handleMouseLeave);
-
- return () => {
- main.removeEventListener("mousemove", handleMouseMove);
- main.removeEventListener("mouseleave", handleMouseLeave);
- };
- }, []);
-
useEffect(() => {
const title = titles[titleIndex];
const typingSpeed = isDeleting ? 40 : 110;
diff --git a/src/sections/Home/Sponser/Sponser.jsx b/src/sections/Home/Sponser/Sponser.jsx
index 78d0e6d..313116f 100644
--- a/src/sections/Home/Sponser/Sponser.jsx
+++ b/src/sections/Home/Sponser/Sponser.jsx
@@ -48,61 +48,6 @@ const Sponser = () => {
};
}, []);
- useEffect(() => {
- if (typeof window !== "undefined" && window.innerWidth <= 768) {
- return undefined;
- }
-
- const track = trackRef.current;
- if (!track) return undefined;
-
- const container = track.parentElement;
- if (!container) return undefined;
-
- let animationFrameId;
-
- const updateScale = () => {
- const containerRect = container.getBoundingClientRect();
- const containerCenter = containerRect.left + containerRect.width / 2;
-
- const cards = track.children;
- const maxDistance = window.innerWidth <= 640 ? 110 : 160;
-
- for (let i = 0; i < cards.length; i++) {
- const card = cards[i];
- if (!card) continue;
- const cardRect = card.getBoundingClientRect();
- const cardCenter = cardRect.left + cardRect.width / 2;
-
- const distance = Math.abs(cardCenter - containerCenter);
-
- let scale = 1;
- let boxShadow = "";
- let zIndex = "";
-
- if (distance < maxDistance) {
- const factor = (maxDistance - distance) / maxDistance;
- scale = 1 + factor * 0.25;
- const glowOpacity = factor * 0.7;
- boxShadow = `0 0 ${12 + factor * 22}px rgba(255, 138, 0, ${glowOpacity}), 0 0 ${6 + factor * 10}px rgba(255, 138, 0, ${factor * 0.4})`;
- zIndex = "10";
- }
-
- card.style.transform = `scale(${scale})`;
- card.style.boxShadow = boxShadow;
- card.style.zIndex = zIndex;
- }
-
- animationFrameId = requestAnimationFrame(updateScale);
- };
-
- updateScale();
-
- return () => {
- cancelAnimationFrame(animationFrameId);
- };
- }, []);
-
const logos = [...SponserImg, ...SponserImg];
return (
diff --git a/src/sections/Home/Sponser/styles/Sponser.module.scss b/src/sections/Home/Sponser/styles/Sponser.module.scss
index cb983f2..aa5e840 100644
--- a/src/sections/Home/Sponser/styles/Sponser.module.scss
+++ b/src/sections/Home/Sponser/styles/Sponser.module.scss
@@ -99,11 +99,6 @@
box-shadow 0.25s var(--ease);
}
-.sponser_card:hover {
- border-color: rgba(255, 138, 0, 0.85);
- box-shadow: 0 0 15px rgba(255, 138, 0, 0.35);
-}
-
.SponserCard_image {
height: 100%;
width: 100%;
diff --git a/src/sections/LiveEvents/Pixel_AI_Hack/Attend/styles/Attend.module.scss b/src/sections/LiveEvents/Pixel_AI_Hack/Attend/styles/Attend.module.scss
index d6eee02..301666e 100644
--- a/src/sections/LiveEvents/Pixel_AI_Hack/Attend/styles/Attend.module.scss
+++ b/src/sections/LiveEvents/Pixel_AI_Hack/Attend/styles/Attend.module.scss
@@ -80,10 +80,6 @@
text-align: left;
transition: transform 0.3s;
- &:hover {
- transform: scale(1.05);
- }
-
@media (max-width: 768px) {
max-width: 80%;
}
diff --git a/src/sections/LiveEvents/Pixel_AI_Hack/Hero/styles/Hero.module.scss b/src/sections/LiveEvents/Pixel_AI_Hack/Hero/styles/Hero.module.scss
index f19ccc6..3121cee 100644
--- a/src/sections/LiveEvents/Pixel_AI_Hack/Hero/styles/Hero.module.scss
+++ b/src/sections/LiveEvents/Pixel_AI_Hack/Hero/styles/Hero.module.scss
@@ -100,8 +100,6 @@
&:hover {
background-color: #FFD389;
transition: 0.3s;
- scale: 1.1;
-
}
}
diff --git a/src/sections/LiveEvents/Pixel_AI_Hack/LiveInsights/styles/LiveInsights.module.scss b/src/sections/LiveEvents/Pixel_AI_Hack/LiveInsights/styles/LiveInsights.module.scss
index ac21458..4d63cd5 100644
--- a/src/sections/LiveEvents/Pixel_AI_Hack/LiveInsights/styles/LiveInsights.module.scss
+++ b/src/sections/LiveEvents/Pixel_AI_Hack/LiveInsights/styles/LiveInsights.module.scss
@@ -36,7 +36,6 @@
&:hover {
background-color: #FFD389;
transition: 0.3s;
- scale: 1.1;
}
}
diff --git a/src/sections/Profile/Admin/Form/BlogForm/styles/AddBlogForm.module.scss b/src/sections/Profile/Admin/Form/BlogForm/styles/AddBlogForm.module.scss
index 5dff544..cb9c847 100644
--- a/src/sections/Profile/Admin/Form/BlogForm/styles/AddBlogForm.module.scss
+++ b/src/sections/Profile/Admin/Form/BlogForm/styles/AddBlogForm.module.scss
@@ -126,7 +126,6 @@
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
- &:hover,
&:focus-within {
border-color: rgba(255, 138, 0, 0.6);
}
@@ -390,25 +389,18 @@
img {
width: 24px;
height: 24px;
- transition: transform 0.2s ease;
- }
-
- &:hover img {
- transform: scale(1.1);
}
}
/* Animation remains same */
@keyframes pulse {
- 0% {
- transform: scale(1);
+ 0%,
+ 100% {
+ opacity: 1;
}
40% {
- transform: scale(1.4);
- }
- 100% {
- transform: scale(1);
+ opacity: 0.7;
}
}
diff --git a/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx b/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx
index efd7e3c..9f3db9d 100644
--- a/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx
+++ b/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useRef, useEffect, useContext } from "react";
-import { Button, Input, Text, Section } from "../../../../../components";
+import { Button, Input, Text, Section, MarkdownEditor } from "../../../../../components";
import { IoSettingsOutline, IoSettingsSharp } from "react-icons/io5";
import AuthContext from "../../../../../context/AuthContext";
import { PreviewForm } from "../../../../../features";
@@ -1481,11 +1481,10 @@ function NewForm() {
width: "45%",
}}
>
-
setdata({ ...data, eventdescription: e.target.value })
diff --git a/src/sections/Profile/Admin/View/VerifyCertificate/styles/VerifyCertificate.module.scss b/src/sections/Profile/Admin/View/VerifyCertificate/styles/VerifyCertificate.module.scss
index 10943f4..fa11445 100644
--- a/src/sections/Profile/Admin/View/VerifyCertificate/styles/VerifyCertificate.module.scss
+++ b/src/sections/Profile/Admin/View/VerifyCertificate/styles/VerifyCertificate.module.scss
@@ -58,9 +58,6 @@ $shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
border: 2px solid $accent-color;
transition: transform 0.3s;
- &:hover {
- transform: scale(1.02);
- }
@media(max-width: 390px) {
padding: 0.1rem;
width: 85%;
@@ -153,7 +150,6 @@ $shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
&:hover {
background-color: color.adjust($accent-color, $lightness: -10%);
- transform: scale(1.05);
}
}
@@ -186,11 +182,6 @@ $shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
width: 32px;
height: 32px;
cursor: pointer;
- transition: transform 0.2s ease;
-
- &:hover {
- transform: scale(1.1);
- }
}
}
.actionButtons{
@@ -208,6 +199,5 @@ $shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
&:hover {
background-color: color.adjust($accent-color, $lightness: -10%);
- transform: scale(1.05);
}
}
\ No newline at end of file
diff --git a/src/services/client/axiosClient.ts b/src/services/client/axiosClient.ts
index 71a5931..4309c42 100644
--- a/src/services/client/axiosClient.ts
+++ b/src/services/client/axiosClient.ts
@@ -13,4 +13,16 @@ const axiosClient = axios.create({
withCredentials: true,
});
+axiosClient.interceptors.request.use((config) => {
+ if (typeof window !== "undefined") {
+ const token = window.localStorage.getItem("token");
+ if (token && !config.headers.Authorization) {
+ config.headers.Authorization = token;
+ }
+ // ngrok free tier shows an interstitial that can break API calls from XHR.
+ config.headers["ngrok-skip-browser-warning"] = "1";
+ }
+ return config;
+});
+
export default axiosClient;
diff --git a/src/utils/batchRestriction.js b/src/utils/batchRestriction.js
new file mode 100644
index 0000000..c911d73
--- /dev/null
+++ b/src/utils/batchRestriction.js
@@ -0,0 +1,46 @@
+/**
+ * Client-side mirror of `lib/batch-restriction.ts` (pure helpers only).
+ */
+
+export function currentBatchEmailPrefix(now = new Date()) {
+ return String(now.getFullYear()).slice(-2);
+}
+
+export function emailLocalPart(email) {
+ return email.split("@")[0]?.trim().toLowerCase() ?? "";
+}
+
+export function isCurrentBatchEmail(email, now = new Date()) {
+ if (!email) return false;
+ const local = emailLocalPart(email);
+ const prefix = currentBatchEmailPrefix(now);
+ return local.length >= prefix.length && local.startsWith(prefix);
+}
+
+export function batchRegistrationErrorMessage(now = new Date()) {
+ const year = now.getFullYear();
+ const prefix = currentBatchEmailPrefix(now);
+ return `Registration is not open for ${year} batch students (emails starting with ${prefix}). If you feel this is an error, contact fedkiit@gmail.com.`;
+}
+
+export function isBatchRegistrationBlocked(email, now = new Date()) {
+ return isCurrentBatchEmail(email, now);
+}
+
+/** Strip common markdown syntax for short card previews. */
+export function stripMarkdownForPreview(text, maxLength = 160) {
+ if (!text) return "";
+ const plain = String(text)
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/`{1,3}([^`]+)`{1,3}/g, "$1")
+ .replace(/^#{1,6}\s+/gm, "")
+ .replace(/(\*\*|__)(.*?)\1/g, "$2")
+ .replace(/(\*|_)(.*?)\1/g, "$2")
+ .replace(/^\s*[-*+]\s+/gm, "")
+ .replace(/^\s*\d+\.\s+/gm, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ if (plain.length <= maxLength) return plain;
+ return `${plain.slice(0, maxLength).trimEnd()}…`;
+}
diff --git a/src/utils/googleOAuth.js b/src/utils/googleOAuth.js
new file mode 100644
index 0000000..ba16ec1
--- /dev/null
+++ b/src/utils/googleOAuth.js
@@ -0,0 +1,4 @@
+export const GOOGLE_OAUTH_CLIENT_ID =
+ process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID?.trim() || "";
+
+export const isGoogleOAuthEnabled = () => Boolean(GOOGLE_OAUTH_CLIENT_ID);
diff --git a/src/views/AttendancePage/AttendancePage.jsx b/src/views/AttendancePage/AttendancePage.jsx
index cb62fea..d24d3d9 100644
--- a/src/views/AttendancePage/AttendancePage.jsx
+++ b/src/views/AttendancePage/AttendancePage.jsx
@@ -1,6 +1,13 @@
"use client";
-import React, { useState, useEffect, useContext, useRef } from "react";
+import React, {
+ useState,
+ useEffect,
+ useContext,
+ useRef,
+ useCallback,
+} from "react";
+import { Html5Qrcode } from "html5-qrcode";
import { EventCard } from "../../components";
import { Button } from "../../components/Core";
import AuthContext from "../../context/AuthContext";
@@ -8,8 +15,34 @@ import { api } from "../../services";
import styles from "./styles/AttendancePage.module.scss";
import { IoClose } from "react-icons/io5";
import { FaDownload } from "react-icons/fa";
-import { Alert, ComponentLoading } from "../../microInteraction";
-import { Html5QrcodeScanner, Html5QrcodeScanType } from "html5-qrcode";
+import { showAlert, ComponentLoading } from "../../microInteraction";
+
+const STATS_POLL_MS = 15000;
+const API_TIMEOUT_MS = 15000;
+
+function sortEvents(events) {
+ return [...events].sort((a, b) => {
+ const priorityA = parseInt(a.info?.eventPriority ?? "99", 10);
+ const priorityB = parseInt(b.info?.eventPriority ?? "99", 10);
+ const dateA = new Date(a.info?.eventDate ?? 0);
+ const dateB = new Date(b.info?.eventDate ?? 0);
+ const titleA = a.info?.eventTitle || "";
+ const titleB = b.info?.eventTitle || "";
+
+ if (priorityA !== priorityB) return priorityA - priorityB;
+ if (dateA.getTime() !== dateB.getTime()) return dateA - dateB;
+ return titleA.localeCompare(titleB);
+ });
+}
+
+function errorCode(error) {
+ const errors = error?.response?.data?.errors;
+ return Array.isArray(errors) ? errors[0]?.code : undefined;
+}
+
+function isNetworkError(error) {
+ return !error?.response && Boolean(error?.message);
+}
const AttendancePage = () => {
const [ongoingEvents, setOngoingEvents] = useState([]);
@@ -18,43 +51,67 @@ const AttendancePage = () => {
const [error, setError] = useState(null);
const [showScanner, setShowScanner] = useState(false);
const [selectedEventId, setSelectedEventId] = useState(null);
- const [scanner, setScanner] = useState(null);
+ const [selectedEventTitle, setSelectedEventTitle] = useState("");
const [isScanning, setIsScanning] = useState(false);
- const [showSuccessModal, setShowSuccessModal] = useState(false);
- const [attendedUser, setAttendedUser] = useState(null);
- const [hasShownAlert, setHasShownAlert] = useState(false);
- const [isSuccess, setIsSuccess] = useState(false);
+ const [eventStats, setEventStats] = useState({ registered: 0, present: 0 });
+
const authCtx = useContext(AuthContext);
const processingRef = useRef(false);
+ const closingRef = useRef(false);
+ const html5QrRef = useRef(null);
+ const selectedEventIdRef = useRef(null);
+ const showScannerRef = useRef(false);
+ const authTokenRef = useRef(authCtx.token);
+
+ authTokenRef.current = authCtx.token;
+ selectedEventIdRef.current = selectedEventId;
+ showScannerRef.current = showScanner;
+
+ const authHeaders = useCallback(
+ () => ({ Authorization: `Bearer ${authTokenRef.current}` }),
+ [],
+ );
- useEffect(() => {
- const fetchEvents = async () => {
+ const fetchEventStats = useCallback(
+ async (eventId) => {
+ if (!eventId) return;
try {
- const response = await api.get("/api/form/getAllForms");
+ const response = await api.get(`/api/form/attendance-stats/${eventId}`, {
+ headers: authHeaders(),
+ timeout: API_TIMEOUT_MS,
+ });
if (response.status === 200) {
- const fetchedEvents = response.data.events;
-
- // sort events by priority, date, and title
- const sortedEvents = fetchedEvents.sort((a, b) => {
- const priorityA = parseInt(a.info.eventPriority, 10);
- const priorityB = parseInt(b.info.eventPriority, 10);
- const dateA = new Date(a.info.eventDate);
- const dateB = new Date(b.info.eventDate);
- const titleA = a.info.eventTitle || "";
- const titleB = b.info.eventTitle || "";
-
- if (priorityA !== priorityB) return priorityA - priorityB;
- if (dateA.getTime() !== dateB.getTime()) return dateA - dateB;
- return titleA.localeCompare(titleB);
+ setEventStats({
+ registered: response.data.registered ?? 0,
+ present: response.data.present ?? 0,
});
+ }
+ } catch {
+ // Non-blocking.
+ }
+ },
+ [authHeaders],
+ );
- const ongoing = sortedEvents.filter(e => !e.info.isEventPast);
- const past = sortedEvents
- .filter(e => e.info.isEventPast)
- .sort((a, b) => new Date(b.info.eventDate) - new Date(a.info.eventDate));
-
- setOngoingEvents(ongoing);
- setPastEvents(past);
+ useEffect(() => {
+ const fetchEvents = async () => {
+ try {
+ const response = await api.get("/api/form/attendance-events", {
+ headers: authHeaders(),
+ timeout: API_TIMEOUT_MS,
+ });
+ if (response.status === 200) {
+ const sorted = sortEvents(response.data.events ?? []);
+ setOngoingEvents(sorted.filter((e) => !e.info?.isEventPast));
+ setPastEvents(
+ sorted
+ .filter((e) => e.info?.isEventPast)
+ .sort(
+ (a, b) =>
+ new Date(b.info?.eventDate ?? 0) -
+ new Date(a.info?.eventDate ?? 0),
+ ),
+ );
} else {
setError("Error fetching events");
}
@@ -66,181 +123,220 @@ const AttendancePage = () => {
}
};
fetchEvents();
- }, []);
+ }, [authHeaders]);
- const initializeScanner = () => {
+ const pauseScanner = async () => {
+ const scanner = html5QrRef.current;
+ if (!scanner?.isScanning) return;
try {
- const qrScanner = new Html5QrcodeScanner(
- "qr-reader",
- {
- fps: 10,
- qrbox: { width: 250, height: 250 },
- aspectRatio: 1,
- supportedScanTypes: [Html5QrcodeScanType.SCAN_TYPE_CAMERA]
- },
- false
- );
+ await scanner.pause(true);
+ } catch {
+ // Some browsers reject pause; processingRef still guards duplicates.
+ }
+ };
- qrScanner.render(onScanSuccess, onScanFailure);
- setScanner(qrScanner);
- } catch (error) {
- console.error("Error initializing scanner:", error);
- if (!hasShownAlert) {
- Alert({
- type: "error",
- message: "Failed to initialize QR scanner",
- position: "top-right",
- });
- setHasShownAlert(true);
- }
- setShowScanner(false);
+ const resumeScanner = async () => {
+ if (!showScannerRef.current || closingRef.current) return;
+ const scanner = html5QrRef.current;
+ if (!scanner?.isScanning) return;
+ try {
+ await scanner.resume();
+ } catch {
+ // Ignore resume failures; admin can close and reopen.
}
};
+ const stopScanner = useCallback(async () => {
+ const scanner = html5QrRef.current;
+ if (!scanner) return;
+ html5QrRef.current = null;
+ try {
+ if (scanner.isScanning) await scanner.stop();
+ scanner.clear();
+ } catch (err) {
+ console.warn("Scanner stop:", err);
+ }
+ }, []);
+
+ const closeScanner = useCallback(async () => {
+ closingRef.current = true;
+ await stopScanner();
+ setShowScanner(false);
+ setSelectedEventId(null);
+ setSelectedEventTitle("");
+ closingRef.current = false;
+ }, [stopScanner]);
+
const onScanSuccess = async (decodedText) => {
- if (processingRef.current) return;
+ const token = decodedText?.trim();
+ const eventId = selectedEventIdRef.current;
+
+ if (!token || processingRef.current || closingRef.current || !eventId) return;
+
processingRef.current = true;
setIsScanning(true);
- console.log("QR Code scanned successfully:", decodedText);
- console.log("Selected Event ID:", selectedEventId);
-
+ await pauseScanner();
+
try {
- // jwt token from qr code
const response = await api.post(
- `/api/form/markAttendance`,
- {
- formId: selectedEventId,
- token: decodedText,
- },
- {
- headers: {
- Authorization: `Bearer ${authCtx.token}`,
- },
- }
+ "/api/form/markAttendance",
+ { formId: eventId, token },
+ { headers: authHeaders(), timeout: API_TIMEOUT_MS },
);
- if (response.status === 200) {
- // A 200 only ever means "newly marked". A second scan of the same QR
- // comes back as 400 "Attendance already marked." and is handled in the
- // catch below — the API has no success-with-a-flag form.
- //
- // The response is `{ message, attendance }`; there is no `user` key, so
- // this keeps the whole body. The success modal only checks that it is
- // truthy.
- setAttendedUser(response.data.user || response.data);
- setIsSuccess(true);
- if (scanner) {
- scanner.clear();
- }
- setShowSuccessModal(true);
- setIsScanning(false);
-
- // show success alert
- Alert({
- type: "success",
- message: "Attendance marked successfully!",
- position: "top-right",
- });
-
- return; // exit early
- }
- } catch (error) {
- console.error("Error marking attendance:", error);
-
- // "Already marked" is the expected outcome of scanning the same QR twice,
- // not a failure — the volunteer at the door needs to know the person is
- // already through, not see a red error. The API signals it exactly as the
- // Express controller does (markAttendance.js:154): a 400 carrying this
- // message, with no machine-readable code to key off, so the message is
- // what has to be matched.
- const apiMessage = error.response?.data?.message;
- if (
- error.response?.status === 400 &&
- typeof apiMessage === "string" &&
- apiMessage.toLowerCase().includes("already marked")
- ) {
- if (scanner) {
- try {
- scanner.clear();
- } catch (clearError) {
- console.error("Error clearing scanner:", clearError);
- }
- }
- setShowScanner(false);
- setScanner(null);
- Alert({
+ const { alreadyMarked } = response.data ?? {};
+
+ if (alreadyMarked) {
+ showAlert({
type: "info",
- message: apiMessage,
+ id: "attendance-already-checked-in",
+ message: "Already checked in",
position: "top-right",
+ duration: 3000,
});
+ await fetchEventStats(eventId);
+ await resumeScanner();
return;
}
- let errorMessage = "Failed to verify QR code";
-
- if (error.response?.status === 401) {
- // 401 covers two different things: a bad or expired QR ("Invalid or
- // expired QR.") and the *scanner's* own session having lapsed ("Token
- // is required"). Showing the API's wording keeps a signed-out volunteer
- // from blaming the participant's QR code.
+ await fetchEventStats(eventId);
+ showAlert({
+ type: "success",
+ id: "attendance-marked-success",
+ message: "Attendance marked successfully!",
+ position: "top-right",
+ duration: 3000,
+ });
+ await closeScanner();
+ } catch (err) {
+ console.error("Error marking attendance:", err);
+ const code = errorCode(err);
+ const apiMessage = err.response?.data?.message;
+ let errorMessage = apiMessage || "Failed to verify QR code";
+
+ if (isNetworkError(err)) {
+ errorMessage = "Network error — check your connection and try again";
+ } else if (err.response?.status === 401) {
+ if (apiMessage === "Token is required") {
+ errorMessage = "Your session expired — please log in again";
+ await closeScanner();
+ showAlert({ type: "error", message: errorMessage, position: "top-right" });
+ return;
+ }
errorMessage = apiMessage || "Invalid or expired QR code";
- } else if (error.response?.status === 400) {
- errorMessage = error.response?.data?.message || "Invalid request";
- } else if (error.response?.status === 404) {
- errorMessage = "Attendance record not found";
- } else if (error.response?.status === 403) {
+ } else if (code === "INVALID_QR") {
+ errorMessage = "Invalid or expired QR code";
+ } else if (code === "WRONG_EVENT") {
+ errorMessage = "This QR belongs to a different event";
+ } else if (code === "CONFLICT") {
+ errorMessage = "Could not mark attendance — scan again";
+ } else if (err.response?.status === 403) {
errorMessage = "You don't have permission to mark attendance";
- } else if (error.response?.data?.message) {
- errorMessage = error.response.data.message;
- }
-
- // show error alert
- if (!hasShownAlert) {
- Alert({
- type: "error",
- message: errorMessage,
- position: "top-right",
- });
- setHasShownAlert(true);
+ } else if (err.response?.status === 404) {
+ errorMessage = "Attendance record not found";
}
+
+ showAlert({
+ type: "error",
+ id: `attendance-scan-error-${code || err.response?.status || "network"}`,
+ message: errorMessage,
+ position: "top-right",
+ });
+ await resumeScanner();
} finally {
setIsScanning(false);
processingRef.current = false;
}
};
- const onScanFailure = (error) => {
- console.warn(`QR Code scanning failed: ${error}`);
- };
+ const onScanFailure = () => {};
- const handleScanQR = (eventId) => {
- setSelectedEventId(eventId);
- setShowScanner(true);
- setHasShownAlert(false); // reset alert state
- setIsSuccess(false); // reset success state
- processingRef.current = false;
- };
+ const onScanSuccessRef = useRef(onScanSuccess);
+ onScanSuccessRef.current = onScanSuccess;
- const handleCloseSuccessModal = () => {
- setShowSuccessModal(false);
- setAttendedUser(null);
- // auto open scanner for next scan
- setTimeout(() => {
- setShowScanner(true);
- setHasShownAlert(false); // reset alert state
- setIsSuccess(false); // reset success state
- }, 100);
- };
+ const startScanner = useCallback(async () => {
+ if (html5QrRef.current || closingRef.current) return;
+ if (!document.getElementById("qr-reader")) return;
+
+ try {
+ const html5Qr = new Html5Qrcode("qr-reader", { verbose: false });
+ html5QrRef.current = html5Qr;
+ await html5Qr.start(
+ { facingMode: "environment" },
+ {
+ fps: 10,
+ qrbox: { width: 260, height: 260 },
+ aspectRatio: 1,
+ disableFlip: false,
+ },
+ (text) => onScanSuccessRef.current(text),
+ onScanFailure,
+ );
+ } catch (err) {
+ console.error("Error initializing scanner:", err);
+ showAlert({
+ type: "error",
+ message: "Could not access the camera. Check permissions and retry.",
+ position: "top-right",
+ });
+ setShowScanner(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!showScanner) {
+ stopScanner();
+ return;
+ }
+ const timer = setTimeout(() => {
+ startScanner();
+ fetchEventStats(selectedEventId);
+ }, 200);
+
+ const statsInterval = setInterval(() => {
+ fetchEventStats(selectedEventId);
+ }, STATS_POLL_MS);
+
+ return () => {
+ clearTimeout(timer);
+ clearInterval(statsInterval);
+ stopScanner();
+ };
+ }, [showScanner, selectedEventId, startScanner, stopScanner, fetchEventStats]);
+ useEffect(() => {
+ return () => {
+ stopScanner();
+ };
+ }, [stopScanner]);
+
+ const handleScanQR = (event) => {
+ if (!authTokenRef.current) {
+ showAlert({
+ type: "error",
+ message: "Please log in to mark attendance",
+ position: "top-right",
+ });
+ return;
+ }
+
+ closingRef.current = false;
+ processingRef.current = false;
+ setSelectedEventId(event.id);
+ setSelectedEventTitle(event.info?.eventTitle || "Event");
+ setEventStats({ registered: 0, present: 0 });
+ setShowScanner(true);
+ };
const handleDownloadAttendance = async (eventId) => {
try {
- const response = await api.get(`/api/form/export-attendance/${eventId}?format=xlsx`, {
- headers: { Authorization: `Bearer ${authCtx.token}` },
+ const response = await api.get(`/api/form/export-attendance/${eventId}`, {
+ headers: authHeaders(),
responseType: "blob",
+ timeout: API_TIMEOUT_MS,
});
- const blob = new Blob([response.data], { type: "text/xlsx" });
+ const blob = new Blob([response.data], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@@ -250,38 +346,29 @@ const AttendancePage = () => {
link.remove();
window.URL.revokeObjectURL(url);
- Alert({
+ showAlert({
type: "success",
- message: "Attendance file downloaded successfully!",
+ message: "Attendance file downloaded",
position: "top-right",
});
- } catch (error) {
+ } catch (err) {
let errorMessage = "Failed to download attendance file";
-
- if (error.response?.status === 403) {
+ if (isNetworkError(err)) {
+ errorMessage = "Network error — could not download file";
+ } else if (err.response?.status === 403) {
errorMessage = "You don't have permission to download attendance data";
- } else if (error.response?.status === 404) {
+ } else if (err.response?.status === 404) {
errorMessage = "Attendance data not found for this event";
- } else if (error.response?.data?.message) {
- errorMessage = error.response.data.message;
+ } else if (err.response?.data?.message) {
+ errorMessage = err.response.data.message;
}
-
- // show error alert
- if (!isSuccess && !hasShownAlert) {
- Alert({
- type: "error",
- message: errorMessage,
- position: "top-right",
- });
- setHasShownAlert(true);
- }
- console.error("Download error:", error);
+ showAlert({ type: "error", message: errorMessage, position: "top-right" });
}
};
const renderOngoingActions = (event) => (
-
);
@@ -310,26 +396,11 @@ const AttendancePage = () => {
style={{ padding: "8px 16px", backgroundColor: "rgba(255, 138, 0, 0.9)" }}
>
- Attendance
+ Attendance
);
- useEffect(() => {
- if (showScanner) {
- initializeScanner();
- }
- return () => {
- if (scanner) {
- try {
- scanner.clear();
- } catch (error) {
- console.error("Error clearing scanner in cleanup:", error);
- }
- }
- };
- }, [showScanner]);
-
if (isLoading) {
return (