From a65979e6d3e3f70d922622b6c399f11322089b25 Mon Sep 17 00:00:00 2001 From: Hardik Gupta Date: Fri, 21 Aug 2026 04:31:34 +0530 Subject: [PATCH 1/2] fix attendance scanner, toasts, and edge cases Harden QR check-in with atomic marks, accurate stats, global toasts, and a simpler admin flow that closes on success and stays open on duplicates. Also includes oauth guards, event skeleton, and glow animation cleanup from the same session. Co-authored-by: Cursor --- app/api/form/attendance-events/route.ts | 27 + app/api/form/attendance-stats/[id]/route.ts | 24 + app/globals.css | 141 +---- app/layout.tsx | 2 + lib/services/attendance.ts | 413 ++++++++++--- prisma/schema.prisma | 2 + src/authentication/Login/GoogleLogin.jsx | 31 +- src/authentication/Login/Login.jsx | 37 +- .../Login/styles/Login.module.scss | 98 ++-- src/authentication/SignUp/GoogleSignup.jsx | 113 ++-- src/authentication/SignUp/SignUP.jsx | 27 +- .../Carousel/styles/Carousel.module.scss | 3 - src/components/Chatbot/Chatbot.jsx | 1 - src/components/Chatbot/Chatbot.module.scss | 53 +- src/components/Core/styles/Core.module.scss | 6 +- .../styles/CustomCursor.module.scss | 8 +- src/components/EventCard/EventCard.jsx | 48 +- .../EventCard/styles/EventCard.module.scss | 116 +++- .../styles/HeroGallery.module.scss | 27 +- src/components/SocialEmbed/SocialEmbed.jsx | 10 +- .../ToastProvider/ToastProvider.jsx | 7 + src/components/index.jsx | 2 +- src/context/Providers.jsx | 25 +- .../Modals/Event/QRCodeModal/QRCodeModal.jsx | 117 ++-- .../styles/QRCodeModal.module.scss | 8 + .../styles/LeftSidebar.module.scss | 11 - .../styles/RightSidebar.module.scss | 1 - src/microInteraction/Alert/Alert.tsx | 195 ++++--- src/microInteraction/index.ts | 2 +- .../Events/styles/EventsSection.module.scss | 11 +- src/sections/Home/Feedback/Feedback.jsx | 66 --- .../Home/Feedback/styles/Feedback.module.scss | 23 - src/sections/Home/Hero/Hero.jsx | 26 - src/sections/Home/Sponser/Sponser.jsx | 55 -- .../Home/Sponser/styles/Sponser.module.scss | 5 - .../Attend/styles/Attend.module.scss | 4 - .../Hero/styles/Hero.module.scss | 2 - .../styles/LiveInsights.module.scss | 1 - .../BlogForm/styles/AddBlogForm.module.scss | 16 +- .../styles/VerifyCertificate.module.scss | 10 - src/utils/googleOAuth.js | 4 + src/views/AttendancePage/AttendancePage.jsx | 546 ++++++++++-------- .../styles/AttendancePage.module.scss | 24 +- src/views/Blog/styles/Blog.module.scss | 5 - src/views/Blog/styles/FullBlog.module.scss | 7 +- src/views/Event/Event.jsx | 52 +- src/views/Event/styles/Event.module.scss | 80 +-- src/views/Home/Home.jsx | 23 - src/views/Home/styles/Home.module.scss | 41 +- src/views/Social/styles/Social.module.scss | 20 - src/views/Team/styles/Team.module.scss | 8 +- .../styles/TeamManagement.module.scss | 19 - 52 files changed, 1249 insertions(+), 1354 deletions(-) create mode 100644 app/api/form/attendance-events/route.ts create mode 100644 app/api/form/attendance-stats/[id]/route.ts create mode 100644 src/components/ToastProvider/ToastProvider.jsx create mode 100644 src/utils/googleOAuth.js diff --git a/app/api/form/attendance-events/route.ts b/app/api/form/attendance-events/route.ts new file mode 100644 index 0000000..9d67a1f --- /dev/null +++ b/app/api/form/attendance-events/route.ts @@ -0,0 +1,27 @@ +import { + getAttendanceStats, + listAttendanceEvents, +} from "@/lib/services/attendance"; +import { expressError, handle, json } from "@/lib/api/express"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; + +/** + * GET /api/form/attendance-events + * + * Lightweight event list for the attendance scanner — id + info only, no + * sections JSON. Avoids loading every form's full payload on door duty. + */ +export async function GET() { + return handle(async () => { + const user = await getCurrentUser(); + if (!user) return expressError(401, "Token is required"); + if (!isAdmin(user)) return expressError(403, "Unauthorized"); + + const events = await listAttendanceEvents(); + return json({ + success: true, + message: "Events fetched successfully", + events, + }); + }); +} diff --git a/app/api/form/attendance-stats/[id]/route.ts b/app/api/form/attendance-stats/[id]/route.ts new file mode 100644 index 0000000..70df564 --- /dev/null +++ b/app/api/form/attendance-stats/[id]/route.ts @@ -0,0 +1,24 @@ +import { getAttendanceStats } from "@/lib/services/attendance"; +import { expressError, handle, json } from "@/lib/api/express"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; + +/** + * GET /api/form/attendance-stats/:id + * + * Live present / registered counts while scanning at the door. + */ +export async function GET( + _request: Request, + ctx: RouteContext<"/api/form/attendance-stats/[id]">, +) { + return handle(async () => { + const user = await getCurrentUser(); + if (!user) return expressError(401, "Token is required"); + if (!isAdmin(user)) return expressError(403, "Unauthorized"); + + const { id } = await ctx.params; + const stats = await getAttendanceStats(id); + + return json({ success: true, ...stats }); + }); +} diff --git a/app/globals.css b/app/globals.css index 02dcbfb..f7506bb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -19,7 +19,6 @@ --fed-border-hover: rgba(255, 255, 255, 0.16); --fed-orange: #f97316; --fed-orange-dark: #ea580c; - --fed-orange-glow: rgba(249, 115, 22, 0.25); --fed-text: #ffffff; --fed-muted: #888888; --fed-subtle: #555555; @@ -83,7 +82,7 @@ main>div { background-color: transparent !important; } -/* ─── Background Patterns & Glows ───────────────────────────────── */ +/* ─── Background Patterns ───────────────────────────────────────── */ .bg-grid-pattern { background-color: #080808; background-image: @@ -92,28 +91,6 @@ main>div { background-size: 64px 64px; } -.bg-grid-glow { - position: relative; -} - -.bg-top-orange-glow { - position: relative; -} - -.bg-top-orange-glow::before { - content: ""; - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: 1200px; - height: 600px; - background: radial-gradient(ellipse at 50% 0%, rgba(249, 115, 22, 0.18) 0%, rgba(249, 115, 22, 0.05) 45%, transparent 75%); - pointer-events: none; - z-index: 0; -} - /* ─── Responsive Section & Container Layouts ────────────────────── */ .fed-container { width: 100%; @@ -158,33 +135,6 @@ main>div { } } -/* ─── Alternating Section Glow Effects ───────────────────────────── */ -.section-glow-left::before { - content: ""; - position: absolute; - top: 50%; - left: -10%; - transform: translateY(-50%); - width: 55%; - height: 85%; - background: radial-gradient(ellipse at left center, rgba(249, 115, 22, 0.14) 0%, transparent 70%); - pointer-events: none; - z-index: 0; -} - -.section-glow-right::before { - content: ""; - position: absolute; - top: 50%; - right: -10%; - transform: translateY(-50%); - width: 55%; - height: 85%; - background: radial-gradient(ellipse at right center, rgba(249, 115, 22, 0.14) 0%, transparent 70%); - pointer-events: none; - z-index: 0; -} - /* ─── Navbar Styles ──────────────────────────────────────────────── */ .fed-navbar-wrapper { position: fixed; @@ -392,7 +342,6 @@ main>div { .fed-btn-orange:hover { background: #ff6611; transform: none !important; - box-shadow: 0 0 16px rgba(255, 85, 0, 0.55) !important; } .fed-btn-orange--full { @@ -481,7 +430,6 @@ main>div { border: 1px solid rgba(249, 115, 22, 0.7) !important; color: #ffffff !important; font-weight: 700 !important; - box-shadow: inset 0 0 12px rgba(249, 115, 22, 0.3) !important; } @media (min-width: 768px) { @@ -539,17 +487,15 @@ main>div { padding: 0.65rem 1.5rem; border-radius: 9999px; text-decoration: none; - transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; + transition: background 0.2s ease, transform 0.15s ease; white-space: nowrap; border: none; cursor: pointer; - box-shadow: 0 4px 20px var(--fed-orange-glow); } .fed-btn-primary:hover { background: var(--fed-orange-dark); transform: translateY(-1px); - box-shadow: 0 6px 24px rgba(249, 115, 22, 0.45); } .fed-btn-secondary { @@ -565,14 +511,13 @@ main>div { padding: 0.65rem 1.5rem; border-radius: 9999px; text-decoration: none; - transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease; + transition: background 0.2s ease, transform 0.15s ease; white-space: nowrap; cursor: pointer; } .fed-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.22); transform: translateY(-1px); } @@ -598,22 +543,6 @@ main>div { background: var(--fed-orange); border-radius: 50%; flex-shrink: 0; - box-shadow: 0 0 8px var(--fed-orange); - animation: pulse-dot 2s ease-in-out infinite; -} - -@keyframes pulse-dot { - - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - - 50% { - opacity: 0.4; - transform: scale(0.75); - } } /* ─── Typography & Headings ──────────────────────────────────────── */ @@ -645,11 +574,10 @@ main>div { background: var(--fed-surface); border: 1px solid var(--fed-border); border-radius: 24px; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .fed-card:hover { - border-color: var(--fed-border-hover); transform: translateY(-2px); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } @@ -661,11 +589,10 @@ main>div { border-radius: 24px; padding: 2.5rem 1.75rem; text-align: center; - transition: border-color 0.2s ease, transform 0.2s ease; + transition: transform 0.2s ease; } .fed-stat-card:hover { - border-color: rgba(255, 255, 255, 0.16); transform: translateY(-2px); } @@ -698,10 +625,6 @@ main>div { transition: border-color 0.2s ease; } -.fed-img-placeholder:hover { - border-color: rgba(249, 115, 22, 0.6); -} - .fed-img-placeholder-icon { width: 50px; height: 50px; @@ -719,31 +642,16 @@ main>div { border: 1px solid var(--fed-border); border-radius: 24px; overflow: hidden; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .fed-event-card:hover { - border-color: rgba(249, 115, 22, 0.4); transform: translateY(-4px); box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); } .fed-event-card--live { border-color: rgba(34, 197, 94, 0.4); - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.2), 0 0 45px rgba(34, 197, 94, 0.14); - animation: live-pulse 3s ease-in-out infinite; -} - -@keyframes live-pulse { - - 0%, - 100% { - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.2), 0 0 40px rgba(34, 197, 94, 0.12); - } - - 50% { - box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.45), 0 0 65px rgba(34, 197, 94, 0.22); - } } .fed-event-tag { @@ -798,7 +706,7 @@ main>div { } .status-dot--live { - animation: pulse-dot 1.5s ease-in-out infinite; + background: currentColor; } /* ─── Sponsor Cards & Carousel ───────────────────────────────────── */ @@ -847,12 +755,11 @@ main>div { gap: 1rem; min-width: 200px; width: 200px; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; flex-shrink: 0; } .fed-sponsor-card:hover { - border-color: rgba(249, 115, 22, 0.4); transform: translateY(-3px); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); } @@ -867,12 +774,7 @@ main>div { align-items: center; justify-content: center; color: rgba(249, 115, 22, 0.5); - transition: border-color 0.2s ease, background 0.2s ease; -} - -.fed-sponsor-card:hover .fed-sponsor-img-placeholder { - border-color: rgba(249, 115, 22, 0.6); - background: rgba(249, 115, 22, 0.06); + transition: background 0.2s ease; } /* ─── Testimonial Carousel ───────────────────────────────────────── */ @@ -912,8 +814,6 @@ main>div { .testimonial-dot--active { background: var(--fed-orange); - transform: scale(1.35); - box-shadow: 0 0 10px var(--fed-orange-glow); } .testimonial-nav-btn { @@ -927,13 +827,12 @@ main>div { align-items: center; justify-content: center; cursor: pointer; - transition: background 0.2s ease, border-color 0.2s ease; + transition: background 0.2s ease; flex-shrink: 0; } .testimonial-nav-btn:hover { background: rgba(255, 255, 255, 0.14); - border-color: rgba(255, 255, 255, 0.25); } .fed-testimonial-card { @@ -941,19 +840,18 @@ main>div { border: 1px solid var(--fed-border); border-radius: 24px; padding: 2.75rem 2.25rem; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; width: 100%; } .fed-testimonial-card:hover { - border-color: rgba(255, 255, 255, 0.18); transform: translateY(-3px); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } /* ─── CTA Banner ─────────────────────────────────────────────────── */ .fed-cta-banner { - background: radial-gradient(ellipse at 50% 100%, rgba(249, 115, 22, 0.2) 0%, rgba(17, 17, 17, 0.96) 75%); + background: rgba(17, 17, 17, 0.96); border: 1px solid var(--fed-border); border-radius: 28px; padding: 5rem 2.5rem; @@ -1060,17 +958,15 @@ main>div { border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 30px; overflow: hidden; - transition: border-color 0.25s ease, box-shadow 0.25s ease; + transition: box-shadow 0.25s ease; } .event-featured-card:hover { - border-color: rgba(249, 115, 22, 0.45); box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); } .event-featured-card--live { border-color: rgba(34, 197, 94, 0.35); - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 0 60px rgba(34, 197, 94, 0.1); } .event-grid-card { @@ -1080,18 +976,16 @@ main>div { overflow: hidden; display: flex; flex-direction: column; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .event-grid-card:hover { - border-color: rgba(249, 115, 22, 0.35); transform: translateY(-4px); box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); } .event-grid-card--live { border-color: rgba(34, 197, 94, 0.35); - animation: live-pulse 3s ease-in-out infinite; } .event-img-area { @@ -1205,13 +1099,11 @@ main>div { background: rgba(249, 115, 22, 0.42); border-radius: 9999px; border: 1px solid rgba(255, 255, 255, 0.15); - box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.3), 0 0 8px rgba(249, 115, 22, 0.25); - transition: all 0.25s ease; + transition: background 0.25s ease; } ::-webkit-scrollbar-thumb:hover { background: rgba(249, 115, 22, 0.75); - box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.45), 0 0 14px rgba(249, 115, 22, 0.45); } /* ─── Animations ─────────────────────────────────────────────────── */ @@ -1265,12 +1157,11 @@ main>div { align-items: center; text-decoration: none; border-radius: 9999px; - transition: transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease; } .fed-avatar-link:hover { transform: translateY(-1px); - box-shadow: 0 0 0 2px rgba(255, 85, 0, 0.55); } .fed-avatar { diff --git a/app/layout.tsx b/app/layout.tsx index 1f2ddc0..69ffe73 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import Script from "next/script"; import Providers from "@/src/context/Providers"; import Chatbot from "@/src/components/Chatbot/Chatbot"; +import ToastProvider from "@/src/components/ToastProvider/ToastProvider"; import { JsonLd } from "@/components/seo/JsonLd"; import { SITE } from "@/lib/site"; import { SITE_URL } from "@/lib/seo/metadata"; @@ -101,6 +102,7 @@ export default function RootLayout({ AuthContext to attach the signed-in user to a conversation. */} + {children} diff --git a/lib/services/attendance.ts b/lib/services/attendance.ts index a9b0be1..c3e2b59 100644 --- a/lib/services/attendance.ts +++ b/lib/services/attendance.ts @@ -23,17 +23,30 @@ async function signAttendanceToken(attendanceId: string): Promise { .sign(secret()); } -/** - * Attendance and registration export. - * - * Ports controllers/registration/{markAttendance,getAttendanceCode, - * downloadRegistration,exportAttendance}.js. - * - * Spreadsheets are built as CSV rather than through ExcelJS. The original - * streamed a real .xlsx via `workbook.xlsx.writeBuffer()`; CSV opens correctly - * in Excel and Sheets, avoids adding a heavy native-ish dependency to the - * bundle, and sidesteps the SheetJS advisories entirely. - */ +type AttendanceInfo = { + user_name?: string; + user_email?: string; + user_id?: string; +}; + +function attendeeFromRecord( + record: { + teamName: string; + teamCode: string; + info: unknown; + userId: string; + }, + user?: { name: string | null; email: string | null; rollNumber: string | null } | null, +) { + const info = (record.info ?? {}) as AttendanceInfo; + return { + name: info.user_name || user?.name || "", + email: info.user_email || user?.email || "", + rollNumber: user?.rollNumber || "", + teamName: record.teamName, + teamCode: record.teamCode, + }; +} /** RFC 4180 escaping — quotes doubled, fields with delimiters quoted. */ function csvCell(value: unknown): string { @@ -50,55 +63,135 @@ export function toCsv(rows: Array>): string { for (const row of rows) { lines.push(headers.map((h) => csvCell(row[h])).join(",")); } - // Excel needs a BOM to read UTF-8 correctly. return "" + lines.join("\r\n"); } -/** - * The caller's attendance token for an event — encoded into their QR code. - * - * Returns a **signed JWT that expires in 20 minutes**, not the record id. That - * signature is the security control for the whole flow: it is why the Express - * route left its `checkAccess` commented out, and why a scanned code cannot be - * forged or replayed a day later. An earlier version of this port handed back - * the raw `attendance.id`, which was both unauthenticated and permanent — and - * did not match what `QRCodeModal` reads (`response.data.attendanceToken`). - */ -export async function getAttendanceCode( +const registrationSelect = { + teamName: true, + teamCode: true, + value: true, + userId: true, +} as const; + +function userIsOnRegistration( + registration: { userId: string; value: unknown[] }, + userId: string, +): boolean { + if (registration.userId === userId) return true; + return (registration.value as Array<{ user_id?: string }>).some( + (v) => v?.user_id === userId, + ); +} + +/** Participant count across all teams for a form. */ +async function countRegisteredParticipants(formId: string): Promise { + const registrations = await prisma.formRegistration.findMany({ + where: { formId }, + select: { userId: true, value: true }, + }); + + const userIds = new Set(); + for (const registration of registrations) { + userIds.add(registration.userId); + for (const entry of registration.value ?? []) { + const uid = (entry as { user_id?: string })?.user_id; + if (uid) userIds.add(uid); + } + } + return userIds.size; +} + +function submissionInfoForUser( + registration: { + userId: string; + value: unknown[]; + }, + user: SafeUser, +): AttendanceInfo | undefined { + const fromValue = (registration.value as AttendanceInfo[]).find( + (v) => v?.user_id === user.id, + ); + if (fromValue) return fromValue; + + if (registration.userId === user.id) { + const first = registration.value?.[0] as AttendanceInfo | undefined; + if (first) return { ...first, user_id: user.id }; + return { + user_id: user.id, + user_name: user.name ?? undefined, + user_email: user.email ?? undefined, + }; + } + + return { + user_id: user.id, + user_name: user.name ?? undefined, + user_email: user.email ?? undefined, + }; +} + +/** Finds the registration row for a user on a form (solo leader or team member). */ +async function findUserRegistration( formId: string, user: SafeUser, teamCode?: string | null, ) { - if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); - - // With a teamCode the original looked the team up directly; without one it - // scanned every registration for the form and picked the row containing this - // user. Both branches are preserved, including their distinct 404 messages. - let registration = null; if (teamCode && teamCode.trim() !== "") { - registration = await prisma.formRegistration.findFirst({ - where: { formId, teamCode }, + const registration = await prisma.formRegistration.findFirst({ + where: { formId, teamCode: teamCode.trim() }, + select: registrationSelect, }); if (!registration) { throw new ApiError(404, "Form registration not found."); } - } else { - const all = await prisma.formRegistration.findMany({ where: { formId } }); - registration = - all.find((reg) => - (reg.value as Array<{ user_id?: string }>).some( - (v) => v?.user_id === user.id, - ), - ) ?? null; - if (!registration) { - throw new ApiError(404, "Form registration not found for the user."); + if (!userIsOnRegistration(registration, user.id)) { + throw new ApiError(403, "You are not registered for this team."); } + return registration; } - const info = - (registration.value as Array<{ user_id?: string }>).find( + // Fast path: user registered solo or as team leader (userId on the row). + const owned = await prisma.formRegistration.findFirst({ + where: { formId, userId: user.id }, + select: registrationSelect, + }); + if (owned) return owned; + + // Team member: scan registrations for this form only (value[] holds members). + const teams = await prisma.formRegistration.findMany({ + where: { formId }, + select: registrationSelect, + }); + const memberOf = teams.find((reg) => + (reg.value as Array<{ user_id?: string }>).some( (v) => v?.user_id === user.id, - ) ?? undefined; + ), + ); + if (!memberOf) { + throw new ApiError(404, "Form registration not found for the user."); + } + return memberOf; +} + +/** + * The caller's attendance token for an event — encoded into their QR code. + * Returns a signed JWT (20 min), not the raw record id. + */ +export async function getAttendanceCode( + formId: string, + user: SafeUser, + teamCode?: string | null, +) { + if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); + + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { id: true }, + }); + if (!form) throw new ApiError(404, "Form not found"); + + const registration = await findUserRegistration(formId, user, teamCode); + const info = submissionInfoForUser(registration, user); const attendanceData = { formId, @@ -108,38 +201,77 @@ export async function getAttendanceCode( info, }; - const record = await prisma.attendance.upsert({ - where: { - formId_userId_teamCode: { - formId, - userId: user.id, - teamCode: registration.teamCode, - }, - }, - create: attendanceData as never, - update: attendanceData as never, + const uniqueKey = { + formId, + userId: user.id, + teamCode: registration.teamCode, + }; + + const existing = await prisma.attendance.findUnique({ + where: { formId_userId_teamCode: uniqueKey }, + select: { id: true, isPresent: true }, }); + if (existing?.isPresent) { + throw new ApiError(400, "Attendance already marked.", [ + { code: "ALREADY_MARKED" }, + ]); + } + + const record = existing + ? existing + : await prisma.attendance.create({ + data: attendanceData as never, + select: { id: true, isPresent: true }, + }); + + if (existing && info) { + await prisma.attendance.update({ + where: { id: existing.id }, + data: { info }, + }); + } + const token = await signAttendanceToken(record.id); return { message: "Validation id generated successfully.", attendanceToken: token }; } +export type MarkAttendanceResult = { + message: string; + alreadyMarked: boolean; + attendance: { + id: string; + formId: string; + teamName: string; + teamCode: string; + isPresent: boolean; + markedAt: Date | null; + }; + attendee: { + name: string; + email: string; + rollNumber: string; + teamName: string; + teamCode: string; + }; +}; + /** * Marks a scanned attendance record present. - * - * Requires a club member — the original mounted this with its access check - * commented out, so any signed-in user could mark anyone present. + * Uses an atomic conditional update to prevent duplicate marks under concurrency. */ export async function markAttendance(input: { formId?: string; token?: string; -}) { +}): Promise { const token = input.token?.trim(); if (!token) throw new ApiError(400, "Attendance token is required."); - // The scanned value is the signed QR token, not a record id. Verifying it is - // what stops a stale or hand-crafted QR from marking anyone present. + if (!input.formId || !/^[a-f\d]{24}$/i.test(input.formId)) { + throw new ApiError(400, "Valid event ID is required."); + } + let attendanceId: string | undefined; try { const { payload } = await jwtVerify(token, secret(), { @@ -147,31 +279,133 @@ export async function markAttendance(input: { }); attendanceId = payload.attendanceToken as string | undefined; } catch { - throw new ApiError(401, "Invalid or expired QR."); + throw new ApiError(401, "Invalid or expired QR.", [{ code: "INVALID_QR" }]); + } + + if (!attendanceId || !/^[a-f\d]{24}$/i.test(attendanceId)) { + throw new ApiError(401, "Invalid or expired QR.", [{ code: "INVALID_QR" }]); } - if (!attendanceId) { - throw new ApiError(400, "Attendance ID is missing in the token."); + const markedAt = new Date(); + const updated = await prisma.attendance.updateMany({ + where: { + id: attendanceId, + formId: input.formId, + isPresent: false, + }, + data: { isPresent: true, markedAt }, + }); + + if (updated.count === 1) { + const record = await prisma.attendance.findUnique({ + where: { id: attendanceId }, + select: { + id: true, + formId: true, + teamName: true, + teamCode: true, + isPresent: true, + markedAt: true, + info: true, + userId: true, + }, + }); + if (!record) { + throw new ApiError(404, "Attendance record not found.", [{ code: "NOT_FOUND" }]); + } + + const user = await prisma.user.findUnique({ + where: { id: record.userId }, + select: { name: true, email: true, rollNumber: true }, + }); + + return { + message: "Attendance marked successfully.", + alreadyMarked: false, + attendance: { + id: record.id, + formId: record.formId, + teamName: record.teamName, + teamCode: record.teamCode, + isPresent: record.isPresent, + markedAt: record.markedAt, + }, + attendee: attendeeFromRecord(record, user), + }; } const record = await prisma.attendance.findUnique({ where: { id: attendanceId }, + select: { + id: true, + formId: true, + teamName: true, + teamCode: true, + isPresent: true, + markedAt: true, + info: true, + userId: true, + }, }); - if (!record) throw new ApiError(404, "Attendance record not found."); - // A QR for one event must not check someone in at another. + if (!record) { + throw new ApiError(404, "Attendance record not found.", [{ code: "NOT_FOUND" }]); + } if (record.formId !== input.formId) { - throw new ApiError(400, "QR does not belong to the specified form."); + throw new ApiError(400, "QR does not belong to this event.", [ + { code: "WRONG_EVENT" }, + ]); + } + if (record.isPresent) { + const user = await prisma.user.findUnique({ + where: { id: record.userId }, + select: { name: true, email: true, rollNumber: true }, + }); + return { + message: "Attendance already marked.", + alreadyMarked: true, + attendance: { + id: record.id, + formId: record.formId, + teamName: record.teamName, + teamCode: record.teamCode, + isPresent: record.isPresent, + markedAt: record.markedAt, + }, + attendee: attendeeFromRecord(record, user), + }; } - if (record.isPresent) throw new ApiError(400, "Attendance already marked."); + throw new ApiError(409, "Could not mark attendance. Please scan again.", [ + { code: "CONFLICT" }, + ]); +} - const updated = await prisma.attendance.update({ - where: { id: attendanceId }, - data: { isPresent: true, markedAt: new Date() }, +/** Lightweight event list for the attendance scanner page (no sections payload). */ +export async function listAttendanceEvents() { + const forms = await prisma.form.findMany({ + select: { id: true, info: true }, + orderBy: { id: "desc" }, + }); + return forms; +} + +/** Present / total counts for an event — shown while scanning. */ +export async function getAttendanceStats(formId: string) { + if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); + + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { id: true }, }); + if (!form) throw new ApiError(404, "Form not found"); - return { message: "Attendance marked successfully.", attendance: updated }; + const [registered, present] = await Promise.all([ + countRegisteredParticipants(formId), + prisma.attendance.count({ where: { formId, isPresent: true } }), + ]); + + return { registered, present }; } export type StoredField = { name?: string; type?: string; value?: unknown }; @@ -186,13 +420,6 @@ export type StoredSubmission = { const isHttpUrl = (v: unknown): v is string => typeof v === "string" && /^https?:\/\//i.test(v); -/** - * Pulls the payment answers out of a stored submission. - * - * Located by shape and by name pattern rather than by a fixed index, because - * admins can rename and reorder the sections a form is built from. Shared with - * the payments endpoint so the two cannot drift. - */ export function paymentFromSubmission(submission: StoredSubmission): { utr: string; screenshot: string | null; @@ -202,8 +429,6 @@ export function paymentFromSubmission(submission: StoredSubmission): { ); const utr = fields.find((f) => /utr|transaction/i.test(f?.name ?? "")); - // Matched on the stored URL rather than on `type`: a renamed field still - // uploads to the same place, and a media field left blank is null. const screenshot = fields.find( (f) => (f?.type === "image" || f?.type === "file") && isHttpUrl(f?.value), ); @@ -214,7 +439,6 @@ export function paymentFromSubmission(submission: StoredSubmission): { }; } -/** Flattens a registration's stored submission into spreadsheet columns. */ function flattenRegistration(row: { teamName: string; teamCode: string; @@ -255,7 +479,6 @@ function flattenRegistration(row: { return out; } -/** All registrations for a form, as spreadsheet rows. */ export async function exportRegistrations(formId: string) { if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); @@ -284,7 +507,6 @@ export async function exportRegistrations(formId: string) { }; } -/** Attendance rows for a form, as spreadsheet rows. */ export async function exportAttendance(formId: string) { if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); @@ -294,18 +516,27 @@ export async function exportAttendance(formId: string) { }); if (!form) throw new ApiError(404, "Form not found"); - const records = await prisma.attendance.findMany({ where: { formId } }); + const records = await prisma.attendance.findMany({ + where: { formId }, + select: { + userId: true, + teamName: true, + teamCode: true, + isPresent: true, + isPaymentVerified: true, + markedAt: true, + }, + }); const userIds = [...new Set(records.map((r) => r.userId))]; - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, name: true, email: true, rollNumber: true }, - }); + const users = userIds.length + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true, rollNumber: true }, + }) + : []; const byId = new Map(users.map((u) => [u.id, u])); - // Payment proof lives on the registration, not on the attendance record, so - // it has to be joined in. Without this the attendance sheet gave a desk - // volunteer no way to check a payment against what the participant uploaded. const registrations = await prisma.formRegistration.findMany({ where: { formId }, select: { userId: true, value: true }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fef3bb8..91a66b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -203,6 +203,8 @@ model attendance { markedAt DateTime? @@unique([formId, userId, teamCode]) + @@index([formId]) + @@index([formId, isPresent]) @@map("attendance") } diff --git a/src/authentication/Login/GoogleLogin.jsx b/src/authentication/Login/GoogleLogin.jsx index 7c2c009..5a5eda1 100644 --- a/src/authentication/Login/GoogleLogin.jsx +++ b/src/authentication/Login/GoogleLogin.jsx @@ -4,15 +4,15 @@ import { useContext, useState, useEffect } from "react"; import style from "../SignUp/style/Signup.module.scss"; import { useGoogleLogin } from "@react-oauth/google"; -import axios from "axios"; import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import google from "../../assets/images/google.png"; import { Alert, MicroLoading } from "../../microInteraction"; import { api } from "../../services"; import { useRouter } from "next/navigation"; import postAuthRedirect from "../../utils/postAuthRedirect"; +import { isGoogleOAuthEnabled } from "../../utils/googleOAuth"; -export default function GoogleLogin() { +function GoogleLoginButton() { const [alert, setAlert] = useState(null); const [codeResponse, setCodeResponse] = useState(null); const [shouldNavigate, setShouldNavigate] = useState(false); @@ -22,7 +22,7 @@ export default function GoogleLogin() { const [isLoading, setIsLoading] = useState(false); const login = useGoogleLogin({ - onSuccess: (codeResponse) => setCodeResponse(codeResponse), + onSuccess: (response) => setCodeResponse(response), onError: (error) => console.error("Login failed:", error), }); @@ -40,7 +40,6 @@ export default function GoogleLogin() { } }, [alert]); - // `replace`, not `push`: App.jsx redirected with . useEffect(() => { if (shouldNavigate) { router.replace(navigatePath); @@ -57,8 +56,6 @@ export default function GoogleLogin() { }); if (response.status === 200 || response.status === 201) { - // User exists in the backend - // console.log(response); const user = response.data.user; setAlert({ @@ -89,18 +86,10 @@ export default function GoogleLogin() { user.regForm, user.blurhash, response.data.token, - SESSION_TTL_MS + SESSION_TTL_MS, ); - // App.jsx re-rendered /Login as once isLoggedIn - // flipped; nothing watches that flag under the App Router, so the - // navigation this component was already wired for is triggered here. setShouldNavigate(true); }, 800); - - } else { - - // console.log("Unexpected backend response status:", response.status); - // handleFallbackOrSignup(googleUserData); } } catch (error) { setAlert({ @@ -111,7 +100,6 @@ export default function GoogleLogin() { }); console.error("Backend API call failed:", error); - } } catch (error) { console.error("Login error:", error); @@ -129,6 +117,7 @@ export default function GoogleLogin() { return ( <> - + ); } + +export default function GoogleSignup({ setAlert }) { + if (!isGoogleOAuthEnabled()) { + return null; + } + + return ; +} diff --git a/src/authentication/SignUp/SignUP.jsx b/src/authentication/SignUp/SignUP.jsx index 2148755..bf9e429 100644 --- a/src/authentication/SignUp/SignUP.jsx +++ b/src/authentication/SignUp/SignUP.jsx @@ -7,6 +7,7 @@ import { useContext, useState } from "react"; import styles from "./style/Signup.module.scss"; import { Input, Button, Text } from "../../components"; import GoogleSignup from "./GoogleSignup"; +import { isGoogleOAuthEnabled } from "../../utils/googleOAuth"; import bcrypt from "bcryptjs"; import { useEffect } from "react"; @@ -315,18 +316,20 @@ const SignUp = () => { SignUp -
-
-

or

-
-
+ {isGoogleOAuthEnabled() ? ( +
+
+

or

+
+
+ ) : null}
diff --git a/src/components/Carousel/styles/Carousel.module.scss b/src/components/Carousel/styles/Carousel.module.scss index 5f53189..3280158 100644 --- a/src/components/Carousel/styles/Carousel.module.scss +++ b/src/components/Carousel/styles/Carousel.module.scss @@ -102,9 +102,6 @@ cursor: pointer; } -.pagination_dot:hover { - transform: scale(1.2); -} .pagination_dot_active { background-color: steelblue; diff --git a/src/components/Chatbot/Chatbot.jsx b/src/components/Chatbot/Chatbot.jsx index 6f2170a..f081101 100644 --- a/src/components/Chatbot/Chatbot.jsx +++ b/src/components/Chatbot/Chatbot.jsx @@ -380,7 +380,6 @@ const Chatbot = () => { aria-label="Open Chat" > -
)} diff --git a/src/components/Chatbot/Chatbot.module.scss b/src/components/Chatbot/Chatbot.module.scss index 42aae49..3f15c66 100644 --- a/src/components/Chatbot/Chatbot.module.scss +++ b/src/components/Chatbot/Chatbot.module.scss @@ -118,37 +118,7 @@ $border-radius-medium: 16px; backdrop-filter: blur($glass-blur); &:hover { - transform: scale(1.1); animation: none; - box-shadow: 0 12px 32px rgba(244, 43, 3, 0.5); - } - - &:active { - transform: scale(0.95); - } -} - -.pulseRing { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 100%; - height: 100%; - border-radius: 50%; - border: 3px solid rgba(255, 190, 11, 0.6); - animation: pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -@keyframes pulseRing { - 0% { - transform: translate(-50%, -50%) scale(1); - opacity: 1; - } - - 100% { - transform: translate(-50%, -50%) scale(1.5); - opacity: 0; } } @@ -292,7 +262,6 @@ $border-radius-medium: 16px; } &:active { - transform: scale(0.95); } } @@ -393,7 +362,7 @@ $border-radius-medium: 16px; cursor: default; &:hover { - transform: translateY(-2px) scale(1.01); + transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); } } @@ -403,21 +372,13 @@ $border-radius-medium: 16px; color: #1a1a1a; border-bottom-right-radius: 4px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - - &:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); - } } .botMessage { background: $primary-gradient; color: $light-text; border-bottom-left-radius: 4px; - box-shadow: 0 2px 8px rgba(244, 43, 3, 0.2); - - &:hover { - box-shadow: 0 8px 24px rgba(255, 140, 40, 0.35); - } + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); // Style bullet lists inside bot messages ul { @@ -532,9 +493,7 @@ $border-radius-medium: 16px; &:hover { background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 190, 11, 0.5); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(255, 190, 11, 0.2); } &:active { @@ -583,8 +542,6 @@ $border-radius-medium: 16px; &:focus { outline: none; background: rgba(255, 255, 255, 0.12); - border-color: #ff5500; - box-shadow: 0 0 0 3px rgba(255, 85, 0, 0.25); } } @@ -607,7 +564,6 @@ $border-radius-medium: 16px; &:hover { background: rgba(255, 255, 255, 0.16); - border-color: rgba(255, 255, 255, 0.25); } &:active { @@ -617,8 +573,6 @@ $border-radius-medium: 16px; &.listening { background: linear-gradient(135deg, #ff5500 0%, #ff8a00 100%); border-color: transparent; - animation: pulse 1.5s ease-in-out infinite; - box-shadow: 0 0 20px rgba(255, 85, 0, 0.5); } } @@ -638,11 +592,10 @@ $border-radius-medium: 16px; color: #ffffff; box-sizing: border-box; transition: all 0.2s ease; - box-shadow: 0 4px 14px rgba(255, 85, 0, 0.35); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); &:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 6px 18px rgba(255, 85, 0, 0.5); } &:active:not(:disabled) { diff --git a/src/components/Core/styles/Core.module.scss b/src/components/Core/styles/Core.module.scss index 954eb0e..739174f 100644 --- a/src/components/Core/styles/Core.module.scss +++ b/src/components/Core/styles/Core.module.scss @@ -84,10 +84,6 @@ flex-shrink: 0; transition: all 0.2s ease; - &:hover { - border-color: #ff8a00 !important; - } - &:checked { background-color: #ff5500 !important; border-color: #ff5500 !important; @@ -106,7 +102,7 @@ } &:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 138, 0, 0.4); + box-shadow: none; } } diff --git a/src/components/CustomCursor/styles/CustomCursor.module.scss b/src/components/CustomCursor/styles/CustomCursor.module.scss index a33a394..90c8b12 100644 --- a/src/components/CustomCursor/styles/CustomCursor.module.scss +++ b/src/components/CustomCursor/styles/CustomCursor.module.scss @@ -8,14 +8,12 @@ margin-left: -4px; border-radius: 50%; background: var(--primary, #ff8a00); - box-shadow: 0 0 10px rgba(255, 138, 0, 0.9), 0 0 20px rgba(255, 138, 0, 0.5); pointer-events: none; z-index: 99999; will-change: transform; - transition: transform 0.15s ease, opacity 0.3s ease; + transition: opacity 0.3s ease; &.hovered { - transform: scale(1.6); background: #ffa826; } @@ -38,16 +36,14 @@ pointer-events: none; z-index: 99998; will-change: transform; - transition: width 0.25s ease, height 0.25s ease, margin 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, opacity 0.3s ease; + transition: width 0.25s ease, height 0.25s ease, margin 0.25s ease, background-color 0.25s ease, opacity 0.3s ease; &.hovered { width: 52px; height: 52px; margin-top: -26px; margin-left: -26px; - border-color: rgba(255, 138, 0, 0.95); background: rgba(255, 138, 0, 0.14); - box-shadow: 0 0 20px rgba(255, 138, 0, 0.3); } &.hidden { diff --git a/src/components/EventCard/EventCard.jsx b/src/components/EventCard/EventCard.jsx index 4f26ca8..af22d78 100644 --- a/src/components/EventCard/EventCard.jsx +++ b/src/components/EventCard/EventCard.jsx @@ -18,6 +18,33 @@ import { useRouter } from "next/navigation"; import { isPrerequisiteMet } from "../../utils/prerequisite"; import { cdn } from "../../utils/cloudinary"; +export function EventCardSkeleton({ variant = "default" }) { + const featured = variant === "featured"; + + return ( + + ); +} + const EventCard = (props) => { const { data, @@ -394,26 +421,7 @@ const EventCard = (props) => { }; if (isLoading || showSkeleton) { - const featured = variant === "featured"; - return ( -