diff --git a/.gitignore b/.gitignore
index 00822be..f880714 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,7 +30,12 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
-# env files (can opt-in for committing if needed)
+# env files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
.env*
# ...but the template carries no secrets and documents the contract.
!.env.example
@@ -41,3 +46,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/app/generated/prisma
diff --git a/app/(main)/Events/[eventId]/Form/page.jsx b/app/(main)/Events/[eventId]/Form/page.jsx
index 1f54d0d..0289ba1 100644
--- a/app/(main)/Events/[eventId]/Form/page.jsx
+++ b/app/(main)/Events/[eventId]/Form/page.jsx
@@ -2,7 +2,6 @@
import { Suspense } from "react";
-import Event from "@/src/views/Event/Event";
import EventForm from "@/src/views/Event/EventForm";
import ProtectedRoute from "@/src/components/ProtectedRoute";
import { Loading } from "@/src/microInteraction";
@@ -10,6 +9,10 @@ import { Loading } from "@/src/microInteraction";
/**
* /Events/:eventId/Form — the registration form, behind the auth guard.
*
+ * The event listing is no longer rendered underneath. It was only there to sit
+ * behind the overlay; now that the form is a page it would mean fetching and
+ * painting every event just to hide it under an opaque card.
+ *
* Suspense is required: both EventForm and ProtectedRoute read
* `useSearchParams()`. It is declared per-page rather than in the layout, since
* a layout-level boundary made every prerendered page emit its markup twice.
@@ -18,7 +21,6 @@ export default function Page() {
return (
}>
-
diff --git a/app/(main)/Events/[eventId]/page.jsx b/app/(main)/Events/[eventId]/page.jsx
index e36b29f..fe88aea 100644
--- a/app/(main)/Events/[eventId]/page.jsx
+++ b/app/(main)/Events/[eventId]/page.jsx
@@ -1,19 +1,14 @@
"use client";
-import Event from "@/src/views/Event/Event";
-import EventModal from "@/src/features/Modals/Event/EventModal/EventModal";
+import EventDetail from "@/src/views/Event/EventDetail";
/**
* /Events/:eventId
*
- * App.jsx rendered `[, ]` — the
- * listing stays mounted underneath and the modal opens over it.
+ * Was `[, ]` — the listing stayed
+ * mounted and a fixed overlay opened on top of it. It is a page of its own now,
+ * so the listing is no longer fetched and painted underneath just to be covered.
*/
export default function Page() {
- return (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/app/(main)/layout.jsx b/app/(main)/layout.jsx
index a2b4a6a..7681e27 100644
--- a/app/(main)/layout.jsx
+++ b/app/(main)/layout.jsx
@@ -2,7 +2,10 @@
import { usePathname } from "next/navigation";
-import Navbar from "@/src/layouts/Navbar/Navbar";
+// The revamped navbar. Its links point at the canonical capitalised routes
+// (/Events, /Team, /Blog), so it drives the existing pages rather than
+// replacing them. The previous SCSS navbar is still in the tree, unused.
+import Navbar from "@/app/components/Navbar";
import Footer from "@/src/layouts/Footer/Footer";
/**
@@ -23,12 +26,28 @@ import Footer from "@/src/layouts/Footer/Footer";
*/
export default function MainLayout({ children }) {
const pathname = usePathname();
- const isOmegaPage = pathname?.toLowerCase() === "/omega";
+ const path = pathname?.toLowerCase() ?? "";
+ const isOmegaPage = path === "/omega";
+
+ // The navbar is fixed, so every page has to reserve space for it or its first
+ // element renders behind the pill. `.page` used to carry `margin-top: 88px`
+ // for exactly this, but globals.css zeroes it with `!important` across body,
+ // .page and main — which is what put the headings on /Team, /Alumni and
+ // /profile underneath the navbar.
+ //
+ // Home and Omega opt out: both open with a full-bleed hero that is meant to
+ // run up behind a transparent navbar, and an offset there would leave a band
+ // of empty page above it.
+ const isFullBleed = path === "/" || isOmegaPage;
return (
-
+
{children}
diff --git a/app/(main)/profile/attendance/page.jsx b/app/(main)/profile/attendance/page.jsx
index fe0e791..3ef9e6e 100644
--- a/app/(main)/profile/attendance/page.jsx
+++ b/app/(main)/profile/attendance/page.jsx
@@ -1,9 +1,28 @@
// Route entry — renders the component ported from
// FED-Frontend/src/pages/AttendancePage/AttendancePage.jsx
-"use client";
+//
+// Gated to ADMIN on the server. The sidebar only ever *showed* this link to
+// admins, but that is presentation: `proxy.ts` guards /profile by checking for
+// a valid session, not a role, so before this check any signed-in participant
+// who typed the path got a working scanner. Combined with the fact that a
+// participant can generate their own QR (that is the whole point of
+// QRCodeModal), it meant anyone could mark themselves present.
+//
+// The QR-issuing endpoint stays open to any signed-in user — participants must
+// be able to produce their own code. Only scanning is restricted.
+import { redirect } from "next/navigation";
+
+import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import AttendancePage from "@/src/views/AttendancePage/AttendancePage";
-export default function Page() {
+export default async function Page() {
+ const user = await getCurrentUser();
+
+ // Matches what proxy.ts does for an anonymous request to a protected route,
+ // so an expired session lands on the login page rather than a bare redirect.
+ if (!user) redirect("/Login?next=/profile/attendance");
+ if (!isAdmin(user)) redirect("/profile");
+
return ;
}
diff --git a/app/(main)/profile/events/Analytics/[eventId]/page.jsx b/app/(main)/profile/events/Analytics/[eventId]/page.jsx
new file mode 100644
index 0000000..cf8b4e9
--- /dev/null
+++ b/app/(main)/profile/events/Analytics/[eventId]/page.jsx
@@ -0,0 +1,27 @@
+// Route entry for the per-event admin view — registration counts, year
+// breakdown, the CSV export and the payment proofs.
+//
+// This route did not exist. `EventCard` has shipped an analytics button since
+// the revamp that pushes to `/profile/events/Analytics/`, and `EventStats`
+// was written and exported from the modals barrel, but nothing ever mounted it
+// — so the button 404'd and the component was dead code.
+//
+// Gated to ADMIN on the server, matching /profile/attendance: `proxy.ts` only
+// checks for a valid session, not a role, so without this any signed-in
+// participant who typed the path would see every registrant's email and
+// payment screenshot.
+
+import { redirect } from "next/navigation";
+
+import { getCurrentUser, isAdmin } from "@/lib/auth/access";
+import EventStats from "@/src/features/Modals/Event/EventStats/EventStats";
+
+export default async function Page({ params }) {
+ const { eventId } = await params;
+ const user = await getCurrentUser();
+
+ if (!user) redirect(`/Login?next=/profile/events/Analytics/${eventId}`);
+ if (!isAdmin(user)) redirect("/profile");
+
+ return ;
+}
diff --git a/app/(main)/profile/layout.jsx b/app/(main)/profile/layout.jsx
index 95ed9c7..9616b95 100644
--- a/app/(main)/profile/layout.jsx
+++ b/app/(main)/profile/layout.jsx
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import ProfileLayout from "@/src/layouts/Profile/ProfileLayout/ProfileLayout";
import Sidebar from "@/src/layouts/Profile/Sidebar/Sidebar";
-import AuthContext from "@/src/context/AuthContext";
+import AuthContext, { clearServerSession } from "@/src/context/AuthContext";
import { api } from "@/src/services";
import { Loading } from "@/src/microInteraction";
import style from "@/src/views/Profile/styles/Profile.module.scss";
@@ -32,7 +32,11 @@ export default function ProfileShell({
if (authCtx.isLoading) return;
if (!authCtx.isLoggedIn) {
- router.replace("/Login");
+ // Clear the cookie before redirecting. proxy.ts gates /Login on the
+ // cookie alone, so leaving a live one here means it bounces us straight
+ // back to /profile and we land in this branch again — an invisible loop
+ // whose only symptom is that the Login button never opens the form.
+ clearServerSession().finally(() => router.replace("/Login"));
return;
}
diff --git a/app/api/form/addForm/route.ts b/app/api/form/addForm/route.ts
index c042917..ab62505 100644
--- a/app/api/form/addForm/route.ts
+++ b/app/api/form/addForm/route.ts
@@ -56,7 +56,16 @@ export async function POST(request: Request) {
isPublic: text("isPublic") === "true",
isRegistrationClosed: text("isRegistrationClosed") === "true",
isEventPast: text("isEventPast") === "true",
- receiverDetails: { upi: text("upi") ?? null, media: null as string | null },
+ receiverDetails: {
+ upi: text("upi") ?? null,
+ media: null as string | null,
+ // Anything other than "Link" is QR — that is the historical behaviour
+ // and the safe default if the field is missing or malformed.
+ mode: text("paymentMode") === "Link" ? "Link" : "QR",
+ link: text("paymentLink") || null,
+ buttonText: text("paymentButtonText") || null,
+ message: text("paymentMessage") || null,
+ },
};
const eventImg = form.get("eventImg");
diff --git a/app/api/form/editForm/[id]/route.ts b/app/api/form/editForm/[id]/route.ts
index 36fc189..23ba29b 100644
--- a/app/api/form/editForm/[id]/route.ts
+++ b/app/api/form/editForm/[id]/route.ts
@@ -74,6 +74,30 @@ export async function PUT(
info.receiverDetails = { ...(info.receiverDetails ?? {}), upi };
}
+ // Merged one key at a time, like `upi` above: a partial edit must not blank
+ // out settings the request did not carry.
+ const paymentMode = text("paymentMode");
+ if (paymentMode !== undefined) {
+ info.receiverDetails = {
+ ...(info.receiverDetails ?? {}),
+ mode: paymentMode === "Link" ? "Link" : "QR",
+ };
+ }
+
+ for (const [key, field] of [
+ ["paymentLink", "link"],
+ ["paymentButtonText", "buttonText"],
+ ["paymentMessage", "message"],
+ ] as const) {
+ const value = text(key);
+ if (value !== undefined) {
+ info.receiverDetails = {
+ ...(info.receiverDetails ?? {}),
+ [field]: value || null,
+ };
+ }
+ }
+
const eventImg = form.get("eventImg");
if (eventImg instanceof File && eventImg.size > 0) {
const result = await uploadImage(eventImg, "FormImages");
diff --git a/app/api/form/markAttendance/route.ts b/app/api/form/markAttendance/route.ts
index 73dcfe9..c13e7d7 100644
--- a/app/api/form/markAttendance/route.ts
+++ b/app/api/form/markAttendance/route.ts
@@ -1,16 +1,23 @@
import { markAttendance } from "@/lib/services/attendance";
import { body, expressError, handle, json } from "@/lib/api/express";
-import { getCurrentUser } from "@/lib/auth/access";
+import { getCurrentUser, isAdmin } from "@/lib/auth/access";
/**
* POST /api/form/markAttendance
* Port of controllers/registration/markAttendance.js.
*
- * Signed-in callers only, with no access-level check — matching the Express
- * route, which has its `checkAccess` call commented out. That is deliberate on
- * their side: the volunteer scanning at the door signs in as a plain USER, so
- * requiring club-member access locks the door staff out. The real control is
- * the signed, 20-minute QR token, which `markAttendance` verifies.
+ * ADMIN only. This deliberately diverges from the Express route, which has its
+ * `checkAccess` commented out entirely and so accepts unauthenticated calls.
+ *
+ * The QR token alone is not an access control: a participant can mint their own
+ * through /api/form/attendanceCode — that endpoint exists so they can display
+ * their code — and could then post it straight back here to mark themselves
+ * present. Restricting who may *scan* is what closes that, and it has to live
+ * here rather than only on the page, because the page is just a UI over this
+ * call.
+ *
+ * Issuing a code stays open to any signed-in user; only redeeming one is
+ * restricted.
*
* Responds `{ message, attendance }` at the top level, which is the shape
* AttendancePage reads.
@@ -19,6 +26,7 @@ export async function POST(request: Request) {
return handle(async () => {
const user = await getCurrentUser();
if (!user) return expressError(401, "Token is required");
+ if (!isAdmin(user)) return expressError(403, "Unauthorized");
const b = await body<{ formId?: string; token?: string }>(request);
const result = await markAttendance({ formId: b.formId, token: b.token });
diff --git a/app/api/form/payments/[id]/route.ts b/app/api/form/payments/[id]/route.ts
new file mode 100644
index 0000000..04e478d
--- /dev/null
+++ b/app/api/form/payments/[id]/route.ts
@@ -0,0 +1,80 @@
+import { prisma } from "@/lib/db";
+import { expressError, handle, json } from "@/lib/api/express";
+import { getCurrentUser, isAdmin } from "@/lib/auth/access";
+import { paymentFromSubmission } from "@/lib/services/attendance";
+import type { StoredSubmission } from "@/lib/services/attendance";
+import type { EventInfo } from "@/lib/types/event";
+
+/**
+ * GET /api/form/payments/:id — admin only.
+ *
+ * Payment proof for one event's registrations: who paid, what they declared as
+ * their UTR, and the screenshot they uploaded.
+ *
+ * There is no Express counterpart. The original had no way to see an uploaded
+ * screenshot at all — the upload path was commented out in `addRegistration.js`
+ * and the register route discarded file parts — so verifying a payment meant
+ * taking the typed UTR on faith.
+ *
+ * The answers live inside a free-form `sections` blob that admins can rename
+ * and reorder, so fields are located by shape and by name pattern rather than
+ * by a fixed index.
+ */
+
+
+
+
+export async function GET(
+ _request: Request,
+ ctx: RouteContext<"/api/form/payments/[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;
+ if (!/^[a-f\d]{24}$/i.test(id)) return expressError(404, "Form not found");
+
+ const form = await prisma.form.findUnique({
+ where: { id },
+ select: { info: true },
+ });
+ if (!form) return expressError(404, "Form not found");
+
+ const info = (form.info ?? {}) as EventInfo;
+
+ const registrations = await prisma.formRegistration.findMany({
+ where: { formId: id },
+ select: { id: true, teamName: true, teamCode: true, value: true },
+ });
+
+ const payments = registrations.flatMap((registration) =>
+ (registration.value ?? []).map((entry) => {
+ const submission = entry as StoredSubmission;
+ const payment = paymentFromSubmission(submission);
+
+ return {
+ registrationId: registration.id,
+ teamName: registration.teamName,
+ teamCode: registration.teamCode,
+ userName: submission.user_name ?? "",
+ userEmail: submission.user_email ?? "",
+ registeredAt: submission.date_time ?? "",
+ amount: submission.amount ?? String(info.eventAmount ?? "0"),
+ utr: payment.utr,
+ screenshot: payment.screenshot,
+ };
+ }),
+ );
+
+ return json({
+ success: true,
+ eventTitle: info.eventTitle ?? "",
+ eventType: info.eventType ?? "Free",
+ eventAmount: String(info.eventAmount ?? "0"),
+ count: payments.length,
+ payments,
+ });
+ });
+}
diff --git a/app/api/form/register/route.ts b/app/api/form/register/route.ts
index 16684aa..e85c233 100644
--- a/app/api/form/register/route.ts
+++ b/app/api/form/register/route.ts
@@ -7,6 +7,7 @@ import { enforceRateLimit, RATE_LIMITS } from "@/lib/api/rate-limit";
import { getCurrentUser } from "@/lib/auth/access";
import { sendMail } from "@/lib/email/mailer";
import { registrationEmail } from "@/lib/email/templates";
+import { uploadImage } from "@/lib/services/upload";
import type { EventInfo } from "@/lib/types/event";
/**
@@ -32,6 +33,9 @@ import type { EventInfo } from "@/lib/types/event";
*/
const UNAFFILIATED = "UNAFFILIATED";
+/** Cap on any single uploaded answer, e.g. a payment screenshot. */
+const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
+
const isTrue = (v: unknown) => v === true || v === "true";
export async function POST(request: Request) {
@@ -43,6 +47,16 @@ export async function POST(request: Request) {
let formId = "";
let sections: unknown[] = [];
+ /**
+ * File/image answers, keyed by the field *name* — that is how
+ * `PreviewForm.handleSubmit` appends them, one entry per media field.
+ *
+ * These used to be dropped on the floor: the route read `_id` and
+ * `sections` and ignored every file part, while the JSON-encoded `sections`
+ * carried the File objects as `{}`. So a payment screenshot uploaded by a
+ * participant was never stored anywhere.
+ */
+ const uploadEntries: Array<{ name: string; file: File }> = [];
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("multipart/form-data")) {
@@ -56,6 +70,11 @@ export async function POST(request: Request) {
return expressError(400, "sections must be valid JSON");
}
}
+ for (const [key, value] of form.entries()) {
+ if (value instanceof File && value.size > 0) {
+ uploadEntries.push({ name: key, file: value });
+ }
+ }
} else {
const payload = (await request.json().catch(() => ({}))) as {
_id?: string;
@@ -138,6 +157,43 @@ export async function POST(request: Request) {
}
}
+ // Uploaded last, once every rejection above has had its say — an upload for
+ // a registration that then bounces on capacity is a wasted Cloudinary write
+ // that nothing would ever reference.
+ const uploadedByField = new Map();
+ for (const { name, file } of uploadEntries) {
+ if (file.size > MAX_UPLOAD_BYTES) {
+ return expressError(400, `${name} must be smaller than 5 MB`);
+ }
+ if (!file.type.startsWith("image/")) {
+ return expressError(400, `${name} must be an image`);
+ }
+ const result = await uploadImage(file, "PaymentScreenshots");
+ if (!result) {
+ return expressError(500, `Could not upload ${name}. Please try again.`);
+ }
+ uploadedByField.set(name, result.secure_url);
+ }
+
+ // Swap each media field's placeholder for the URL it was uploaded to. The
+ // client serialises a File as `{}`, so without this the stored answer is an
+ // empty object — which is also why an unmatched media field is nulled
+ // rather than left alone.
+ sections = sections.map((section) => {
+ const s = section as {
+ fields?: Array<{ name?: string; type?: string; value?: unknown }>;
+ };
+ if (!Array.isArray(s.fields)) return section;
+ return {
+ ...s,
+ fields: s.fields.map((field) => {
+ if (field?.type !== "file" && field?.type !== "image") return field;
+ const url = field?.name ? uploadedByField.get(field.name) : undefined;
+ return { ...field, value: url ?? null };
+ }),
+ };
+ });
+
const teamCode = `SOLO-${user.id}-${randomInt(1000, 10000)}`;
const capacity = Number.parseInt(String(info.eventMaxReg ?? ""), 10);
diff --git a/app/apple-icon.png b/app/apple-icon.png
new file mode 100644
index 0000000..4dbb25a
Binary files /dev/null and b/app/apple-icon.png differ
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
new file mode 100644
index 0000000..186455c
--- /dev/null
+++ b/app/components/Navbar.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import { useState, useEffect, useRef, useContext } from "react";
+import Link from "next/link";
+import Image from "next/image";
+import { usePathname } from "next/navigation";
+import { MdOutlineLogout } from "react-icons/md";
+
+import AuthContext from "@/src/context/AuthContext";
+import defaultImg from "@/src/assets/images/defaultImg.jpg";
+
+const navLinks = [
+ { href: "/", label: "Home" },
+ { href: "/Events", label: "Events" },
+ { href: "/Team", label: "Team" },
+ { href: "/Blog", label: "Insights" },
+];
+
+export default function Navbar() {
+ const pathname = usePathname();
+ const authCtx = useContext(AuthContext);
+ const [scrolled, setScrolled] = useState(false);
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [visible, setVisible] = useState(true);
+ // Refs, not state: the previous scroll position changes on every tick and
+ // nothing renders from it, so holding it in state forced a re-render per
+ // scroll event — and, because the effect listed it as a dependency, tore the
+ // listener down and re-attached it just as often. That is what made the bar
+ // stutter. `mobileOpenRef` lets the handler read the latest value without
+ // becoming a dependency itself.
+ const lastScrollY = useRef(0);
+ const ticking = useRef(false);
+ const mobileOpenRef = useRef(mobileOpen);
+ // Synced in an effect rather than assigned during render, which React's lint
+ // rules reject — refs must not be written while rendering.
+ useEffect(() => {
+ mobileOpenRef.current = mobileOpen;
+ }, [mobileOpen]);
+
+ const checkIsActive = (linkHref: string) => {
+ if (!pathname) return false;
+ if (linkHref === "/") {
+ return pathname === "/";
+ }
+ const cleanPath = pathname.toLowerCase();
+ const cleanHref = linkHref.toLowerCase();
+
+ if (cleanHref === "/blog" && (cleanPath.startsWith("/blog") || cleanPath.startsWith("/social") || cleanPath.startsWith("/insights"))) {
+ return true;
+ }
+ return cleanPath === cleanHref || cleanPath.startsWith(`${cleanHref}/`) || cleanPath.startsWith(cleanHref);
+ };
+
+ useEffect(() => {
+ // Work is done once per animation frame rather than once per scroll event.
+ // Browsers fire scroll far more often than they paint, so without this the
+ // component did several times more state work than the screen could show.
+ const update = () => {
+ ticking.current = false;
+ const currentScrollY = window.scrollY;
+
+ // Elongate the bar once past 40px.
+ setScrolled(currentScrollY > 40);
+
+ if (!mobileOpenRef.current) {
+ if (currentScrollY <= 220) {
+ // Always visible near the top, so the elongation is legible.
+ setVisible(true);
+ } else if (currentScrollY > lastScrollY.current + 6) {
+ setVisible(false);
+ } else if (currentScrollY < lastScrollY.current - 6) {
+ setVisible(true);
+ }
+ }
+
+ // Only advance the reference point once the threshold has been crossed.
+ // Updating it every frame meant a slow drag never accumulated past the
+ // threshold, so the bar hunted between shown and hidden.
+ if (Math.abs(currentScrollY - lastScrollY.current) > 6) {
+ lastScrollY.current = currentScrollY;
+ }
+ };
+
+ const handleScroll = () => {
+ if (ticking.current) return;
+ ticking.current = true;
+ window.requestAnimationFrame(update);
+ };
+
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ update();
+ return () => window.removeEventListener("scroll", handleScroll);
+ // Attached once for the lifetime of the component.
+ }, []);
+
+ // Close the mobile menu on route change.
+ //
+ // Adjusted during render rather than in an effect: setting state
+ // synchronously inside an effect triggers a second render pass, which the
+ // project's lint config rejects as an error. This is React's documented
+ // pattern for resetting state when a prop changes.
+ const [lastPathname, setLastPathname] = useState(pathname);
+ if (pathname !== lastPathname) {
+ setLastPathname(pathname);
+ setMobileOpen(false);
+ setVisible(true);
+ }
+
+ return (
+ <>
+ {/* Dynamic Backdrop Blur Overlay (Mobile) */}
+
{
let cardClass = styles.card;
- if (expandDescription) cardClass += ` ${styles.expandedCard}`;
+ if (expandDescription) cardClass += ``;
if (props.isRecentCard) cardClass += ` ${styles.recentCard}`;
if (cardType === 'recent') cardClass += ` ${styles.recentCard}`;
if (cardType === 'trending') cardClass += ` ${styles.trendingCard}`;
@@ -109,7 +109,6 @@ function BlogCard(props) {
resolutionX={32}
resolutionY={32}
punch={1}
- className={styles.blurhash}
/>
)}
+
{
diff --git a/src/components/CloseButton/CloseButton.jsx b/src/components/CloseButton/CloseButton.jsx
new file mode 100644
index 0000000..56aff80
--- /dev/null
+++ b/src/components/CloseButton/CloseButton.jsx
@@ -0,0 +1,53 @@
+"use client";
+
+import PropTypes from "prop-types";
+import Link from "next/link";
+import { X } from "lucide-react";
+import styles from "./styles/CloseButton.module.scss";
+
+/**
+ * The one dismiss control for the whole site.
+ *
+ * Every modal used to draw its own X - different sizes, different hit areas,
+ * some as bare
s with no keyboard access at all. Rendering a Link when
+ * `href` is set keeps that single appearance even where closing means
+ * navigating away rather than unmounting a panel.
+ */
+const CloseButton = ({
+ onClick,
+ href,
+ label = "Close",
+ size = "md",
+ className = "",
+}) => {
+ const classes = `${styles.close} ${styles[size]} ${className}`.trim();
+
+ if (href) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+CloseButton.propTypes = {
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ label: PropTypes.string,
+ size: PropTypes.oneOf(["sm", "md"]),
+ className: PropTypes.string,
+};
+
+export default CloseButton;
diff --git a/src/components/CloseButton/styles/CloseButton.module.scss b/src/components/CloseButton/styles/CloseButton.module.scss
new file mode 100644
index 0000000..4e14a47
--- /dev/null
+++ b/src/components/CloseButton/styles/CloseButton.module.scss
@@ -0,0 +1,33 @@
+.close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-pill);
+ background-color: var(--surface-2);
+ box-shadow: var(--depth);
+ color: var(--text-secondary);
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--text-primary);
+ background-color: var(--surface-3);
+ }
+
+ &:active {
+ box-shadow: var(--depth-strong);
+ }
+}
+
+.md {
+ width: 40px;
+ height: 40px;
+}
+
+.sm {
+ width: 32px;
+ height: 32px;
+}
diff --git a/src/components/Core/Input.jsx b/src/components/Core/Input.jsx
index 56a8fe9..25cca67 100644
--- a/src/components/Core/Input.jsx
+++ b/src/components/Core/Input.jsx
@@ -149,8 +149,11 @@ const Input = (props) => {
options,
name,
showLabel = true,
- className,
- containerClassName,
+ // Defaults to "" because it is interpolated into a template literal below.
+ // Without it, every caller that omits the prop rendered a literal
+ // "undefined" in the class list.
+ className = "",
+ containerClassName = "",
...rest
} = props;
const dateRef = useRef(null);
diff --git a/src/components/Core/Text.jsx b/src/components/Core/Text.jsx
index 0b5539b..3175be5 100644
--- a/src/components/Core/Text.jsx
+++ b/src/components/Core/Text.jsx
@@ -6,7 +6,9 @@ import styles from "./styles/Core.module.scss";
const Text = (props) => {
const {
children,
- className,
+ // Defaults to "" for the same reason as Input: it is interpolated into a
+ // template literal, so an omitted prop became a literal "undefined" class.
+ className = "",
variant = "primary",
type = "h6",
style,
@@ -29,7 +31,10 @@ const Text = (props) => {
case "h6":
return styles.h6;
case "p":
- return styles.body1;
+ // The stylesheet defines `.body2` but never `.body1`, so this arm has
+ // always produced no class — same in the original. Returning "" keeps
+ // the rendering identical without emitting class="undefined".
+ return "";
case "body2":
return styles.body2;
default:
diff --git a/src/components/Core/styles/Core.module.scss b/src/components/Core/styles/Core.module.scss
index e93e6a8..11cd791 100644
--- a/src/components/Core/styles/Core.module.scss
+++ b/src/components/Core/styles/Core.module.scss
@@ -107,3 +107,58 @@
appearance: none;
margin: 0;
}
+
+/* ------------------------------------------------------------------ mobile */
+
+/* Safari on iOS zooms the whole page in when a focused field's text is smaller
+ * than 16px, and does not zoom back out on blur. At 12px that fired on every
+ * field of the registration form — the page would jump and stay magnified
+ * halfway through signing up, which is where most people are coming from.
+ *
+ * 16px only below the tablet breakpoint, so the compact desktop form is
+ * unchanged. `!important` matches the existing declarations in this file, which
+ * already use it and would otherwise win. */
+@media (max-width: 768px) {
+ .input,
+ .inputTxtArea,
+ .inputSelect,
+ .inputDate {
+ font-size: 16px !important;
+ /* A 16px field needs a little more room than a 12px one, and 44px is the
+ * usual minimum for a comfortable touch target. */
+ min-height: 44px;
+ }
+
+ /* `.inputTxtArea` and `.inputSelect` are declared at a flat `width: 380px`,
+ * which is wider than a 375px phone — they pushed the whole form sideways.
+ * Everything here is fluid instead, and `box-sizing` keeps the border and
+ * padding inside that width rather than adding to it. */
+ .input,
+ .inputTxtArea,
+ .inputSelect {
+ width: 100% !important;
+ box-sizing: border-box;
+ margin-inline: 0;
+ }
+
+ .containerInput {
+ width: 100%;
+ }
+
+ .inputTxtArea {
+ min-height: 120px;
+ }
+
+ .inputDate {
+ width: 100% !important;
+ height: 44px !important;
+ }
+
+ /* Buttons were rendering at 34px tall — under the comfortable touch minimum
+ * on the one control every registration has to end with. */
+ .main_Button {
+ min-height: 44px;
+ font-size: 15px;
+ padding: 10px 18px;
+ }
+}
diff --git a/src/components/CustomCursor/CustomCursor.jsx b/src/components/CustomCursor/CustomCursor.jsx
new file mode 100644
index 0000000..5ac416a
--- /dev/null
+++ b/src/components/CustomCursor/CustomCursor.jsx
@@ -0,0 +1,92 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import styles from "./styles/CustomCursor.module.scss";
+
+const CustomCursor = () => {
+ const dotRef = useRef(null);
+ const ringRef = useRef(null);
+ const [isHovered, setIsHovered] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+ const mousePos = useRef({ x: -100, y: -100 });
+ const ringPos = useRef({ x: -100, y: -100 });
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia("(pointer: fine)").matches) {
+ return undefined;
+ }
+
+ const handleMouseMove = (e) => {
+ mousePos.current = { x: e.clientX, y: e.clientY };
+ if (!isVisible) setIsVisible(true);
+
+ if (dotRef.current) {
+ dotRef.current.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0)`;
+ }
+
+ const target = e.target;
+ if (
+ target &&
+ (target.closest("a, button, input, textarea, select, [role='button'], .clickable") !== null)
+ ) {
+ setIsHovered(true);
+ } else {
+ setIsHovered(false);
+ }
+ };
+
+ const handleMouseLeave = () => {
+ setIsVisible(false);
+ };
+
+ const handleMouseEnter = () => {
+ setIsVisible(true);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseleave", handleMouseLeave);
+ document.addEventListener("mouseenter", handleMouseEnter);
+
+ let animId;
+ const renderRing = () => {
+ ringPos.current.x += (mousePos.current.x - ringPos.current.x) * 0.18;
+ ringPos.current.y += (mousePos.current.y - ringPos.current.y) * 0.18;
+
+ if (ringRef.current) {
+ ringRef.current.style.transform = `translate3d(${ringPos.current.x}px, ${ringPos.current.y}px, 0)`;
+ }
+ animId = requestAnimationFrame(renderRing);
+ };
+ renderRing();
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseleave", handleMouseLeave);
+ document.removeEventListener("mouseenter", handleMouseEnter);
+ cancelAnimationFrame(animId);
+ };
+ }, [isVisible]);
+
+ if (typeof window !== "undefined" && !window.matchMedia("(pointer: fine)").matches) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default CustomCursor;
diff --git a/src/components/CustomCursor/styles/CustomCursor.module.scss b/src/components/CustomCursor/styles/CustomCursor.module.scss
new file mode 100644
index 0000000..a33a394
--- /dev/null
+++ b/src/components/CustomCursor/styles/CustomCursor.module.scss
@@ -0,0 +1,56 @@
+.cursorDot {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 8px;
+ height: 8px;
+ margin-top: -4px;
+ 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;
+
+ &.hovered {
+ transform: scale(1.6);
+ background: #ffa826;
+ }
+
+ &.hidden {
+ opacity: 0;
+ }
+}
+
+.cursorRing {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 36px;
+ height: 36px;
+ margin-top: -18px;
+ margin-left: -18px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(255, 138, 0, 0.65);
+ background: rgba(255, 138, 0, 0.06);
+ 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;
+
+ &.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 {
+ opacity: 0;
+ }
+}
diff --git a/src/components/EventCard/EventCard.jsx b/src/components/EventCard/EventCard.jsx
index f2476b4..4606785 100644
--- a/src/components/EventCard/EventCard.jsx
+++ b/src/components/EventCard/EventCard.jsx
@@ -3,46 +3,35 @@
import React, { useState, useEffect, useContext } from "react";
import PropTypes from "prop-types";
import style from "./styles/EventCard.module.scss";
-import AOS from "aos";
-import "aos/dist/aos.css";
import Share from "../../features/Modals/Event/ShareModal/ShareModal";
import QRCodeModal from "../../features/Modals/Event/QRCodeModal";
-import shareOutline from "../../assets/images/shareOutline.svg";
import { PiClockCountdownDuotone } from "react-icons/pi";
-import { IoIosLock, IoIosStats } from "react-icons/io";
-import { MdGroups } from "react-icons/md";
-
-import { FaUser, FaRupeeSign, FaEye } from "react-icons/fa";
-import { QrCode } from "lucide-react";
-import { parse, differenceInMilliseconds, formatDistanceToNow } from "date-fns";
-import { Button } from "../Core";
+import { IoIosLock } from "react-icons/io";
+import { QrCode, Share2, BarChart3 } from "lucide-react";
+import { parse, differenceInMilliseconds } from "date-fns";
import AuthContext from "../../context/AuthContext";
-import EventCardSkeleton from "../../layouts/Skeleton/EventCard/EventCardSkeleton";
import { Blurhash } from "react-blurhash";
import { Alert, MicroLoading } from "../../microInteraction";
import Link from "next/link";
import { useRouter } from "next/navigation";
-// import useUnixTimestamp from "../../utils/hooks/useUnixTimeStamp";
-
const EventCard = (props) => {
const {
data,
onOpen,
type,
modalpath,
- customStyles = {},
showShareButton = true,
showRegisterButton = true,
additionalContent,
- aosDisable,
onEdit,
onDelete,
enableEdit,
isLoading,
isRegisteredInRelatedEvents,
eventName,
+ variant = "default",
} = props;
const { info } = data;
@@ -58,10 +47,8 @@ const EventCard = (props) => {
const [isMicroLoading, setIsMicroLoading] = useState(false);
const [shouldNavigate, setShouldNavigate] = useState(false);
const [navigatePath, setNavigatePath] = useState("/");
- const [isLocked, setIsLocked] = useState(false);
const [alert, setAlert] = useState(null);
-
useEffect(() => {
if (shouldNavigate) {
router.push(navigatePath);
@@ -77,18 +64,10 @@ const EventCard = (props) => {
}
}, [alert]);
- useEffect(() => {
- if (aosDisable) {
- AOS.init({ disable: true });
- } else {
- AOS.init({ duration: 2000 });
- }
- }, [aosDisable]);
-
useEffect(() => {
const timer = setTimeout(() => {
setShowSkeleton(false);
- }, 500); // Show skeleton for 2 seconds
+ }, 500);
return () => clearTimeout(timer);
}, []);
@@ -101,9 +80,7 @@ const EventCard = (props) => {
}
}, [info.regDateAndTime]);
- const dateStr = info.eventDate;
- const date = new Date(dateStr);
-
+ const date = new Date(info.eventDate);
const day = date.getDate();
const getOrdinalSuffix = (day) => {
@@ -122,26 +99,10 @@ const EventCard = (props) => {
const dayWithSuffix = day + getOrdinalSuffix(day);
const month = date.toLocaleDateString("en-GB", { month: "long" });
- const year = date.getFullYear(); // Get the full year
+ const year = date.getFullYear();
const formattedDate = `${dayWithSuffix} ${month} ${year}`;
- const modifyDateFormat = (dateStr) => {
- // Remove the ordinal suffix from the day
- const ordinalSuffixes = ["st", "nd", "rd", "th"];
- ordinalSuffixes.forEach((suffix) => {
- dateStr = dateStr.replace(suffix, "");
- });
-
- // Parse the date string to a JavaScript Date object
- const regDate = new Date(Date.parse(dateStr));
-
- // Convert the date to the desired ISO format (UTC)
- const isoDateStr = regDate.toISOString();
-
- return isoDateStr;
- };
-
const calculateRemainingTime = () => {
// Parse the regDateAndTime received from backend
const regStartDate = parse(
@@ -151,7 +112,6 @@ const EventCard = (props) => {
);
const now = new Date();
- // Calculate the time difference in milliseconds
const timeDifference = differenceInMilliseconds(regStartDate, now);
if (timeDifference <= 0) {
@@ -159,7 +119,6 @@ const EventCard = (props) => {
return;
}
- // Calculate the days, hours, minutes, and seconds remaining
const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const hours = Math.floor((timeDifference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((timeDifference / (1000 * 60)) % 60);
@@ -182,12 +141,11 @@ const EventCard = (props) => {
setRemainingTime(remaining);
};
- // Example usage in a React component with useEffect to update every second
useEffect(() => {
calculateRemainingTime(); // Initial calculation
- const intervalId = setInterval(calculateRemainingTime, 1000); // Update every second
+ const intervalId = setInterval(calculateRemainingTime, 1000);
- return () => clearInterval(intervalId); // Cleanup on component unmount
+ return () => clearInterval(intervalId);
}, []);
useEffect(() => {
@@ -205,11 +163,7 @@ const EventCard = (props) => {
useEffect(() => {
if (authCtx.isLoggedIn && authCtx.user.regForm) {
- // console.log("Inside Card", isRegisteredInRelatedEvents);
-
if (isRegisteredInRelatedEvents) {
-
- console.log("checking for ", data.id);
if (data?.info?.relatedEvent === "null") {
if (authCtx.user.regForm.includes(data.id)) {
setBtnTxt("Already Registered");
@@ -241,7 +195,6 @@ const EventCard = (props) => {
}
}
} else {
- // setBtnTxt("Locked");
if (authCtx.user.access === "USER") {
if (data?.info?.isRegistrationClosed) {
setBtnTxt("Closed");
@@ -260,16 +213,18 @@ const EventCard = (props) => {
remainingTime,
]);
- const handleShare = () => {
- setOpen(!isOpen);
- };
-
- const handleCloseShare = () => {
- setOpen(false);
+ const handleShare = (e) => {
+ e?.preventDefault?.();
+ e?.stopPropagation?.();
+ setOpen(true);
};
const handleQRCode = () => {
- if (authCtx.isLoggedIn && authCtx.user.regForm && authCtx.user.regForm.includes(data.id)) {
+ if (
+ authCtx.isLoggedIn &&
+ authCtx.user.regForm &&
+ authCtx.user.regForm.includes(data.id)
+ ) {
setQRModalOpen(!isQRModalOpen);
} else if (!authCtx.isLoggedIn) {
setAlert({
@@ -281,7 +236,8 @@ const EventCard = (props) => {
} else {
setAlert({
type: "info",
- message: "You need to register for this event first to get the attendance QR code.",
+ message:
+ "You need to register for this event first to get the attendance QR code.",
position: "bottom-right",
duration: 3000,
});
@@ -318,6 +274,14 @@ const EventCard = (props) => {
};
const handleForm = () => {
+ // "Already Registered" on a team event is the one non-registering action the
+ // primary button performs, so it is handled before the validity gate that
+ // deliberately rejects that state.
+ if (btnTxt === "Already Registered" && info.participationType === "Team") {
+ router.push(`/Events/${data.id}/team`);
+ return null;
+ }
+
if (!isValiedState()) {
return null;
}
@@ -360,269 +324,312 @@ const EventCard = (props) => {
}
};
- const url = window.location.href;
+ const detailsHref = modalpath + data.id;
+ // Built from the origin, not from the current href - appending the id to
+ // whatever page you happen to be on produced links like /Events/x/y.
+ const shareUrl =
+ typeof window === "undefined"
+ ? detailsHref
+ : new URL(detailsHref, window.location.origin).toString();
+ const isPast = type === "past";
+ const isRegistered =
+ authCtx.isLoggedIn &&
+ authCtx.user.regForm &&
+ authCtx.user.regForm.includes(data.id);
+
+ const isUpcoming =
+ !isPast && new Date(info.eventDate).getTime() >= Date.now();
+
+ // Featured cards that are still ahead of us get an "Upcoming" badge so the
+ // spotlight is unambiguous - the green "Open" only answers "can I register".
+ const status =
+ variant === "featured" && isUpcoming
+ ? { label: "Upcoming", tone: "soon" }
+ : isPast
+ ? { label: "Completed", tone: "neutral" }
+ : info.isRegistrationClosed
+ ? { label: "Registration closed", tone: "closed" }
+ : remainingTime
+ ? { label: "Opening soon", tone: "soon" }
+ : { label: "Open", tone: "open" };
+
+ // The primary action is inert in these states, but stays focusable so the
+ // reason for it (an alert, or nothing at all) is still reachable by keyboard.
+ const isCtaInert =
+ btnTxt === "Closed" ||
+ btnTxt === "Already Member" ||
+ (btnTxt === "Already Registered" && info.participationType !== "Team");
+
+ const ctaContent = () => {
+ if (btnTxt === "Closed") {
+ return (
+ <>
+
+ Closed
+ >
+ );
+ }
+ if (btnTxt === "Already Registered") {
+ return info.participationType === "Team" ? "Team details" : "Registered";
+ }
+ if (btnTxt === "Locked") {
+ return (
+ <>
+
+ Locked
+ >
+ );
+ }
+ if (isMicroLoading) {
+ return ;
+ }
+ if (remainingTime) {
+ return (
+ <>
+
+ {btnTxt}
+ >
+ );
+ }
+ if (btnTxt === "Already Member") {
+ return "Already member";
+ }
+ return "Register now";
+ };
if (isLoading || showSkeleton) {
- return ;
+ const featured = variant === "featured";
+ return (
+
+ )}
+
+ {/* Presentational: the stretched title link below covers this area, so a
+ second focusable link here would only duplicate the tab stop. */}
+
+ {!imageLoaded && (
+
- )}
- setImageLoaded(true)}
- />
-
- {formattedDate}
- {type === "ongoing" && showShareButton && (
-
setImageLoaded(true)}
+ />
+
+ {status.label}
+
+
+
+
+ {/* Date, format and price read as one scannable line rather than three
+ separate chips - the card only needs one row of metadata. */}
+
-
- {info.eventTitle && info.eventTitle.length > 14
- ? `${info.eventTitle.substring(0, 14)}...`
- : info.eventTitle || "No title available"}
-
-
- {/*
- A
, not the
the original used. This element contains
- `div.price`, which itself contains a
— nesting the HTML parser
- rejects. Client-rendered under Vite that was invisible, because
- React builds the DOM node by node and nothing reparents it. Server
- -rendered it is real markup, so the parser closed the
early and
- `div.price` came out a sibling instead of a child, which React then
- flagged as a hydration mismatch. `.meta` carries the styling the
- `.eventname p` rules gave it, so the result is unchanged.
- */}
- {type === "ongoing" && (
-
);
diff --git a/src/features/Modals/Event/EventStats/styles/EventStats.module.scss b/src/features/Modals/Event/EventStats/styles/EventStats.module.scss
index e69de29..a43c1ef 100644
--- a/src/features/Modals/Event/EventStats/styles/EventStats.module.scss
+++ b/src/features/Modals/Event/EventStats/styles/EventStats.module.scss
@@ -0,0 +1,151 @@
+// Analytics modal shell.
+//
+// This reused `.card` from EventModal.module.scss, which is a fixed `32rem`
+// wide with no height bound. The stats panel is much taller than an event card
+// — counts, a year breakdown, a registrant list and now a grid of payment
+// proofs — so it simply grew past the bottom of the viewport. The inline
+// `overflow-y: auto` on it never engaged, because an element with no height
+// constraint never overflows.
+
+// The navbar wrapper is `z-index: 999` (globals.css). This overlay was 10, so
+// the navbar floated over the modal and cut off its heading.
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+}
+
+.backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem 1rem;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ // Last-resort escape hatch: below roughly 420px tall the panel's own minimum
+ // exceeds the viewport, and without this the top would be unreachable.
+ overflow-y: auto;
+}
+
+.panel {
+ position: relative;
+ z-index: 10;
+ width: min(46rem, 94vw);
+ // Bounded, so the panel scrolls its own content instead of the page.
+ max-height: min(86vh, 900px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: linear-gradient(
+ 90deg,
+ rgba(32, 32, 32, 0.95) -11.52%,
+ rgba(37, 37, 37, 0.95) 106.29%
+ );
+ border: 1.2px solid rgba(214, 214, 214, 0.81);
+ border-radius: 1.26863rem;
+ font-size: 0.75rem;
+ color: #fff;
+ text-align: center;
+ // Overscroll containment keeps a flick at the end of the list from scrolling
+ // the page behind the modal.
+ overscroll-behavior: contain;
+}
+
+.panel a {
+ color: #fff;
+}
+
+.panel::-webkit-scrollbar {
+ width: 6px;
+}
+
+.panel::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 10px;
+}
+
+.panel::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
+
+/* ------------------------------------------------------------------ close */
+
+// `.closeModal` in EventModal.module.scss is `top: -8%`, which puts it outside
+// its own panel — on this modal that landed it up beside the navbar.
+.close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ z-index: 2;
+ padding: 0;
+ border: none;
+ background: none;
+ color: #fff;
+ line-height: 0;
+ cursor: pointer;
+
+ &:hover {
+ color: #f97507;
+ }
+}
+
+/* ----------------------------------------------------- registrant listing */
+
+// Was `.eventEmails`, shared from EventModal.module.scss but only ever used
+// here. Two bugs, both visible with a handful of registrants:
+//
+// * `height: 300px` was fixed rather than a maximum, so the box reserved the
+// full height even for two entries;
+// * the flex container left `align-items` at its `stretch` default, so each
+// card grew to the full 300px instead of hugging its own content.
+.list {
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ align-items: flex-start;
+ gap: 0.75rem;
+ max-height: 300px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ margin: 1rem 2rem 2rem;
+ padding: 1rem;
+ border-radius: 10px;
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.userCard {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1 1 calc(50% - 0.375rem);
+ min-width: 0;
+ max-width: calc(50% - 0.375rem);
+ box-sizing: border-box;
+ padding: 8px 10px;
+ border-radius: 4px;
+ background-color: #2d2d2d;
+}
+
+.userImg {
+ flex: none;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+}
+
+.userEmail {
+ min-width: 0;
+ font-size: 0.8125rem;
+ text-align: left;
+ color: #fff;
+ overflow-wrap: anywhere;
+}
+
+.empty {
+ width: 100%;
+ padding: 1.5rem 0;
+ text-align: center;
+ color: #aaa;
+}
diff --git a/src/features/Modals/Event/QRCodeModal/QRCodeModal.jsx b/src/features/Modals/Event/QRCodeModal/QRCodeModal.jsx
index 54bb53d..99dd33b 100644
--- a/src/features/Modals/Event/QRCodeModal/QRCodeModal.jsx
+++ b/src/features/Modals/Event/QRCodeModal/QRCodeModal.jsx
@@ -48,6 +48,10 @@ const QRCodeModal = ({ onClose, eventId, onAttendanceMarked }) => {
});
if (response.status === 200) {
+ // `{ message, attendanceToken }` at the top level — the shape both the
+ // Express controller (markAttendance.js:99) and this port return. The
+ // value is a signed JWT that expires in 20 minutes, not a record id, so
+ // it is what has to be encoded into the QR image.
setQrCodeData(response.data.attendanceToken);
} else {
throw new Error('Failed to fetch attendance code');
diff --git a/src/features/Modals/Profile/Admin/PreviewForm.jsx b/src/features/Modals/Profile/Admin/PreviewForm.jsx
index 3a911ab..c8950e0 100644
--- a/src/features/Modals/Profile/Admin/PreviewForm.jsx
+++ b/src/features/Modals/Profile/Admin/PreviewForm.jsx
@@ -40,6 +40,11 @@ const PreviewForm = ({
handleClose,
showCloseBtn,
teamCode, // [v2] invite link team code
+ // Renders in normal document flow as a page card instead of the fixed
+ // full-screen overlay. Only the three wrapper elements differ — the form
+ // itself is one copy, so a change to a step cannot land in one mode and not
+ // the other.
+ inline = false,
}) => {
const router = useRouter();
const authCtx = useContext(AuthContext);
@@ -71,8 +76,11 @@ const PreviewForm = ({
}, 1000);
}, []);
+ // Locking the page behind the form only makes sense for the overlay, where
+ // the form has its own scroller. Inline it is the page, and freezing the body
+ // leaves a form taller than the viewport with no way to reach its own Submit.
useEffect(() => {
- if (open) {
+ if (open && !inline) {
document.body.classList.add(styles.noScroll);
} else {
document.body.classList.remove(styles.noScroll);
@@ -81,7 +89,7 @@ const PreviewForm = ({
return () => {
document.body.classList.remove(styles.noScroll);
};
- }, [open]);
+ }, [open, inline]);
useEffect(() => {
constructSections();
@@ -540,7 +548,26 @@ const PreviewForm = ({
};
const renderPaymentScreen = () => {
- const { eventType, receiverDetails, eventAmount } = formData;
+ const { eventType, eventAmount } = formData;
+ // Events created before the payment-mode setting have no `receiverDetails`
+ // at all on the free path, and no `mode` on the paid one.
+ const receiverDetails = formData.receiverDetails ?? {};
+ const paymentMode = receiverDetails.mode === "Link" ? "Link" : "QR";
+
+ // The href is admin-entered and rendered for participants, so it is checked
+ // here too rather than trusting the admin form's validation alone — that
+ // check did not exist for events saved before it, and `javascript:` in an
+ // anchor runs on click.
+ const safeLink = (() => {
+ try {
+ const parsed = new URL(receiverDetails.link ?? "");
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
+ ? parsed.href
+ : null;
+ } catch {
+ return null;
+ }
+ })();
const handleDownloadQR = async () => {
try {
@@ -585,6 +612,76 @@ const PreviewForm = ({
}
};
+ if (
+ eventType === "Paid" &&
+ currentSection.name === "Payment Details" &&
+ paymentMode === "Link"
+ ) {
+ return (
+
+ {/*
+ This guard was written without its braces, so `open && (` and the
+ matching `)` were rendered as literal text at the top and bottom of
+ every registration form — participants saw "open && (" above the
+ heading. Both call sites already mount this only when the flag is set,
+ so making it a real conditional changes nothing else.
+ */}
+ {open && (
+
{
style={{
width: "30%",
}}
- className={styles.teamField}
>
* {
+ padding-right: 0;
+}
+
.previewContainer::-webkit-scrollbar {
width: 6px;
}
@@ -147,4 +183,52 @@
width: 115%;
margin-left: -1rem;
}
-}
\ No newline at end of file
+}
+// Applied to while the preview modal is open, via
+// `document.body.classList.add(styles.noScroll)` in PreviewForm.jsx.
+//
+// The component has always referenced it, but no rule existed here — in the
+// original app either — so `styles.noScroll` was `undefined` and the call added
+// a literal "undefined" class. The page behind the modal stayed scrollable.
+// Same declaration the original uses for this class in its other modules.
+.noScroll {
+ overflow: hidden;
+}
+
+/* ------------------------------------------------------------------ mobile */
+
+@media (max-width: 768px) {
+ /* The field row is a wrapping flexbox with a 3rem column gap and a 2rem left
+ * pad — sensible for the two-up desktop form, but on a phone each field
+ * became a shrink-to-content flex item about 200px wide inside a 340px card,
+ * and the left pad pushed everything off centre. One field per row, each
+ * filling the width. */
+ .formFieldContainer {
+ flex-direction: column;
+ align-items: stretch;
+ column-gap: 0;
+ padding: 0.75rem;
+ }
+
+ .formFieldContainer > * {
+ width: 100%;
+ }
+
+ /* The overlay reserves a gutter for its own scrollbar. The page variant has
+ * no scrollbar, and at this width the gutter is visible as a lopsided margin. */
+ .previewContainer {
+ padding: 1.25rem 1rem 1.5rem;
+ }
+
+ .previewContainer > * {
+ padding-right: 0;
+ }
+
+ .inlineContainer {
+ padding: 1.25rem 1rem 1.5rem;
+ }
+
+ .pagePreviewWrapper {
+ border-radius: var(--radius-md);
+ }
+}
diff --git a/src/layouts/Blog/LeftSidebar/LeftSidebar.jsx b/src/layouts/Blog/LeftSidebar/LeftSidebar.jsx
index 5e14b8a..f823190 100644
--- a/src/layouts/Blog/LeftSidebar/LeftSidebar.jsx
+++ b/src/layouts/Blog/LeftSidebar/LeftSidebar.jsx
@@ -39,7 +39,6 @@ function LeftSidebar({
placeholder="Search blogs..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
- className={styles.searchInput}
/>