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) */} +
setMobileOpen(false)} + aria-hidden="true" + /> + +
+ +
+ + ); +} diff --git a/app/components/ScrollRevealWrapper.tsx b/app/components/ScrollRevealWrapper.tsx new file mode 100644 index 0000000..a258086 --- /dev/null +++ b/app/components/ScrollRevealWrapper.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface ScrollRevealWrapperProps { + children: React.ReactNode; + instant?: boolean; +} + +export default function ScrollRevealWrapper({ children, instant = false }: ScrollRevealWrapperProps) { + const domRef = useRef(null); + const [isVisible, setIsVisible] = useState(instant); + + useEffect(() => { + if (instant) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsVisible(entry.isIntersecting); + }, + { + threshold: 0.05, + rootMargin: "-6% 0px -6% 0px", + } + ); + + const current = domRef.current; + if (current) observer.observe(current); + + return () => { + if (current) observer.unobserve(current); + }; + }, [instant]); + + return ( +
+ {children} +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..4cb14a8 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..07f23d7 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,1279 @@ +/* Design tokens and component classes for the revamped Navbar and Home + * sections. Plain CSS on purpose. + * + * The source of this file opened with `@import "tailwindcss"`, but nothing that + * ships here uses a Tailwind utility — every class below is hand-written + * `fed-*` CSS. The only Tailwind-based files were the standalone marketing + * mock-ups, which are not part of this merge. The import is dropped rather than + * wired up: the branch it came from had `tailwindcss` in devDependencies but no + * PostCSS config, so it never compiled there either, and adding a second + * styling system next to 84 SCSS modules is not worth it for zero call sites. + */ + +/* ─── Design Tokens ──────────────────────────────────────────────── */ +:root { + --fed-bg: #080808; + --fed-surface: #111111; + --fed-surface-2: #161616; + --fed-border: rgba(255, 255, 255, 0.08); + --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; + + /* Vertical space the fixed navbar occupies, plus breathing room. + * + * Measured, not estimated: the bar sits at `top: 1.25rem` and renders 62px + * tall, so its lower edge is at 82px. 6.5rem (104px) leaves about 22px of + * clearance. It was 8rem, set from an earlier 80px reading of the bar — that + * reserved 46px of empty band above every page and was enough, on a short + * laptop viewport, to push the Events spotlight card off the bottom. + * + * Declared once here so pages cannot each guess their own value — which is + * how /Events ended up reserving the space twice. */ + --fed-navbar-offset: 6.5rem; +} + +/* ─── Base ───────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + background: #080808; +} + +body, +.page, +main { + background-color: #080808 !important; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.045) 1px, transparent 1px) !important; + background-size: 64px 64px !important; + background-attachment: fixed !important; + background-position: top center !important; + margin-top: 0 !important; + color: var(--fed-text); + font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* `clip`, not `hidden`. This rule applies to body, .page and main at once, + * and `overflow-x: hidden` forces `overflow-y` to compute to `auto`, turning + * all three into scroll containers. A scroll container between a sticky + * element and the viewport stops it sticking, which is why the About + * section's pin had to be driven from JavaScript. `clip` suppresses the same + * sideways overflow without creating a scroll container. */ + overflow-x: clip; + position: relative; +} + +.page>section, +main>section, +.page>div, +main>div { + background-color: transparent !important; +} + +/* ─── Background Patterns & Glows ───────────────────────────────── */ +.bg-grid-pattern { + background-color: #080808; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.045) 1px, transparent 1px); + 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%; + max-width: 1160px; + margin-left: auto; + margin-right: auto; + padding-left: 1.5rem; + padding-right: 1.5rem; + position: relative; + z-index: 1; +} + +@media (min-width: 768px) { + .fed-container { + padding-left: 2.5rem; + padding-right: 2.5rem; + } +} + +.fed-section { + padding-top: 3.5rem; + padding-bottom: 3.5rem; + position: relative; + z-index: 10; + overflow: hidden; +} + +#hero.fed-section { + padding-top: 12.5rem; + padding-bottom: 4rem; +} + +@media (min-width: 768px) { + .fed-section { + padding-top: 5.5rem; + padding-bottom: 5.5rem; + } + + #hero.fed-section { + padding-top: 10rem; + padding-bottom: 6rem; + } +} + +/* ─── 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; + top: 1.25rem; + left: 0; + right: 0; + z-index: 999; + display: flex; + justify-content: center; + padding: 0 1rem; + pointer-events: none; + transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.45s ease; +} + +.fed-navbar-wrapper--hidden { + transform: translateY(-180%); + opacity: 0; +} + +.fed-navbar { + pointer-events: auto; + background: rgba(18, 18, 18, 0.85); + backdrop-filter: blur(24px) saturate(1.8); + -webkit-backdrop-filter: blur(24px) saturate(1.8); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 9999px; + width: 100%; + max-width: 880px; + padding: 0.5rem 0.75rem; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1); + transition: max-width 0.45s cubic-bezier(0.32, 0.72, 0, 1), background 0.45s ease, padding 0.45s cubic-bezier(0.32, 0.72, 0, 1), box-shadow 0.45s ease, transform 0.45s ease; + display: flex; + flex-direction: column; +} + +@media (max-width: 767px) { + .fed-navbar { + border-radius: 28px; + } +} + +.fed-navbar--scrolled { + max-width: 1140px; + padding: 0.55rem 1.25rem; + background: rgba(14, 14, 14, 0.94); + border-color: rgba(255, 255, 255, 0.16); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.15); +} + +.fed-navbar--dynamic-island { + border-radius: 28px !important; + background: rgba(16, 16, 16, 0.96) !important; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.9), inset 0 1px 1px rgba(255, 255, 255, 0.18) !important; + padding: 0.65rem 1rem 1rem 1rem !important; + backdrop-filter: blur(32px) saturate(2) !important; + -webkit-backdrop-filter: blur(32px) saturate(2) !important; +} + +.fed-navbar-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 1rem; +} + +.fed-brand-link { + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; + flex-shrink: 0; +} + +.fed-logo-badge { + width: 38px; + height: 38px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.fed-logo-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.fed-logo-text { + font-size: 1.1rem; + font-weight: 800; + letter-spacing: -0.01em; + color: #ffffff; +} + +.fed-nav-pill-container { + display: none; + align-items: center; + gap: 0.25rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 9999px; + padding: 0.25rem; +} + +.fed-nav-link { + display: inline-flex; + align-items: center; + padding: 0.45rem 1.25rem; + border-radius: 9999px; + font-size: 0.9rem; + font-weight: 500; + color: #a6a6a6; + text-decoration: none; + transition: all 0.2s ease; + white-space: nowrap; +} + +.fed-nav-link:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.08); +} + +.fed-nav-link--active { + background: #2a2a2a; + color: #ffffff; + font-weight: 700; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.fed-desktop-action { + display: none; + align-items: center; + flex-shrink: 0; +} + +.fed-btn-orange { + display: inline-flex; + align-items: center; + justify-content: center; + background: #ff5500; + color: #ffffff; + font-weight: 700; + font-size: 0.9rem; + padding: 0.5rem 1.4rem; + border-radius: 9999px; + text-decoration: none; + transition: all 0.25s ease; + border: 1px solid rgba(255, 255, 255, 0.15); + box-shadow: none; +} + +.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 { + width: 100%; + padding: 0.75rem 1.5rem; + border-radius: 16px; + font-size: 1rem; +} + +.fed-mobile-toggle { + display: flex; + align-items: center; +} + +/* Backdrop behind the open mobile menu. + * + * This was the one element in the navbar written with Tailwind utilities + * (`fixed inset-0 z-40 bg-[#080808]/50 backdrop-blur-md transition-all + * duration-500 md:hidden`). Translated 1:1 so the whole navbar is plain CSS and + * Tailwind is not needed as a second styling system. */ +.fed-mobile-backdrop { + position: fixed; + inset: 0; + z-index: 40; + background: rgba(8, 8, 8, 0.5); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: all 0.5s; + opacity: 0; + pointer-events: none; +} + +.fed-mobile-backdrop--open { + opacity: 1; + pointer-events: auto; +} + +@media (min-width: 768px) { + .fed-mobile-backdrop { + display: none; + } +} + +.fed-mobile-menu-container { + display: block; + overflow: hidden; + transition: all 0.45s cubic-bezier(0.16, 1, 0.3, 1); +} + +.fed-mobile-menu--open { + max-height: 460px; + opacity: 1; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.fed-mobile-menu--closed { + max-height: 0; + opacity: 0; + margin-top: 0; + padding-top: 0; + border-top: 0; + pointer-events: none; +} + +.fed-mobile-menu-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding-bottom: 0.5rem; + margin-top: 0.5rem; +} + +.clay-nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1.25rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1); + color: #aaaaaa; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + text-decoration: none; + overflow: hidden; +} + +.clay-nav-item:hover, +.clay-nav-item:active { + background: rgba(255, 255, 255, 0.09); + color: #ffffff; +} + +.clay-nav-item--active { + background: rgba(249, 115, 22, 0.12) !important; + 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) { + .fed-nav-pill-container { + display: flex; + } + + .fed-desktop-action { + display: flex; + } + + .fed-mobile-toggle { + display: none; + } + + .fed-mobile-menu-container { + display: none !important; + } +} + +.fed-nav-link { + display: inline-flex; + align-items: center; + padding: 0.45rem 1.15rem; + border-radius: 9999px; + font-size: 0.9rem; + font-weight: 500; + color: #999999; + text-decoration: none; + transition: color 0.2s ease, background 0.2s ease; + white-space: nowrap; +} + +.fed-nav-link:hover { + color: var(--fed-text); + background: rgba(255, 255, 255, 0.08); +} + +.fed-nav-link--active { + background: rgba(255, 255, 255, 0.14); + color: var(--fed-text); + font-weight: 600; +} + +/* ─── Buttons ────────────────────────────────────────────────────── */ +.fed-btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--fed-orange); + color: #ffffff; + font-weight: 600; + font-size: 0.9rem; + 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; + 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 { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--fed-text); + font-weight: 600; + font-size: 0.875rem; + 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; + 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); +} + +/* ─── Section Labels & Badges ───────────────────────────────────── */ +.fed-label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 1rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #aaaaaa; +} + +.fed-label-dot { + width: 6px; + height: 6px; + 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 ──────────────────────────────────────── */ +.fed-h1 { + font-size: clamp(2.5rem, 5.5vw, 4.5rem); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.025em; + color: var(--fed-text); +} + +.fed-h2 { + font-size: clamp(1.85rem, 3.8vw, 2.85rem); + font-weight: 800; + line-height: 1.18; + letter-spacing: -0.02em; + color: var(--fed-text); +} + +.fed-orange-italic { + color: var(--fed-orange); + font-family: Georgia, Cambria, "Times New Roman", Times, serif; + font-style: italic; + font-weight: 400; +} + +/* ─── Cards & Grid Components ───────────────────────────────────── */ +.fed-card { + 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; +} + +.fed-card:hover { + border-color: var(--fed-border-hover); + transform: translateY(-2px); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); +} + +.fed-stat-card { + background: rgba(17, 17, 17, 0.85); + backdrop-filter: blur(12px); + border: 1px solid var(--fed-border); + border-radius: 24px; + padding: 2.5rem 1.75rem; + text-align: center; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.fed-stat-card:hover { + border-color: rgba(255, 255, 255, 0.16); + transform: translateY(-2px); +} + +.fed-stat-card h3 { + font-size: clamp(2.2rem, 3.2vw, 3rem); + font-weight: 800; + color: var(--fed-text); + letter-spacing: -0.02em; +} + +.fed-stat-card p { + font-size: 0.9rem; + color: var(--fed-muted); + margin-top: 0.4rem; +} + +/* ─── Image Placeholder ──────────────────────────────────────────── */ +.fed-img-placeholder { + background: rgba(22, 22, 22, 0.9); + border: 1.5px dashed rgba(249, 115, 22, 0.35); + border-radius: 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.875rem; + color: var(--fed-muted); + font-size: 0.85rem; + font-weight: 500; + 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; + background: rgba(249, 115, 22, 0.12); + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + color: var(--fed-orange); +} + +/* ─── Event Cards ────────────────────────────────────────────────── */ +.fed-event-card { + background: rgba(17, 17, 17, 0.9); + 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; +} + +.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 { + display: inline-block; + padding: 0.3rem 0.75rem; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + color: #eeeeee; +} + +/* ─── Status Badges ──────────────────────────────────────────────── */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.status-badge--live { + background: rgba(34, 197, 94, 0.16); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.35); +} + +.status-badge--upcoming { + background: rgba(249, 115, 22, 0.14); + color: #f97316; + border: 1px solid rgba(249, 115, 22, 0.3); +} + +.status-badge--past { + background: rgba(107, 114, 128, 0.14); + color: #9ca3af; + border: 1px solid rgba(107, 114, 128, 0.25); +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-dot--live { + animation: pulse-dot 1.5s ease-in-out infinite; +} + +/* ─── Sponsor Cards & Carousel ───────────────────────────────────── */ +.sponsor-carousel-outer { + overflow: hidden; + position: relative; + width: 100%; + mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%); + -webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%); +} + +.sponsor-carousel-track { + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + width: max-content; + animation: scroll-left 30s linear infinite; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.sponsor-carousel-track:hover { + animation-play-state: paused; +} + +@keyframes scroll-left { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +} + +.fed-sponsor-card { + background: rgba(17, 17, 17, 0.9); + border: 1px solid var(--fed-border); + border-radius: 22px; + padding: 1.75rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + min-width: 200px; + width: 200px; + transition: border-color 0.25s ease, 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); +} + +.fed-sponsor-img-placeholder { + width: 96px; + height: 60px; + background: rgba(255, 255, 255, 0.03); + border: 1.5px dashed rgba(249, 115, 22, 0.3); + border-radius: 12px; + display: flex; + 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); +} + +/* ─── Testimonial Carousel ───────────────────────────────────────── */ +.testimonial-card-animate { + animation: fadeSlideUp 0.5s ease forwards; +} + +@keyframes fadeSlideUp { + from { + opacity: 0; + transform: translateY(14px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.testimonial-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; +} + +.testimonial-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + border: none; + cursor: pointer; + transition: background 0.25s ease, transform 0.25s ease; + padding: 0; +} + +.testimonial-dot--active { + background: var(--fed-orange); + transform: scale(1.35); + box-shadow: 0 0 10px var(--fed-orange-glow); +} + +.testimonial-nav-btn { + width: 42px; + height: 42px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, border-color 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 { + background: rgba(17, 17, 17, 0.9); + 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; + 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%); + border: 1px solid var(--fed-border); + border-radius: 28px; + padding: 5rem 2.5rem; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +/* ─── Hamburger & Mobile UI ─────────────────────────────────────── */ +.hamburger-button { + width: 42px; + height: 42px; + border-radius: 9999px; + background: transparent; + border: none; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + transition: background 0.2s ease, transform 0.2s ease; + cursor: pointer; +} + +.hamburger-button:hover { + background: transparent; +} + +.hamburger { + display: flex; + flex-direction: column; + gap: 5px; + width: 20px; + height: 16px; + justify-content: center; +} + +.hamburger span { + display: block; + height: 2px; + width: 100%; + background: currentColor; + border-radius: 9999px; + transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), + opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: center; +} + +.hamburger.open span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} + +.hamburger.open span:nth-child(2) { + opacity: 0; + transform: scale(0); +} + +.hamburger.open span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* ─── Events Page Styles ─────────────────────────────────────────── */ +.events-filter-tab { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.35rem; + border-radius: 9999px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; + white-space: nowrap; +} + +.events-filter-tab--active { + background: var(--fed-orange); + color: #fff; + box-shadow: 0 4px 20px rgba(249, 115, 22, 0.4); +} + +.events-filter-tab--inactive { + background: rgba(255, 255, 255, 0.05); + color: #888; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.events-filter-tab--inactive:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.event-featured-card { + background: rgba(17, 17, 17, 0.92); + 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; +} + +.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 { + background: rgba(17, 17, 17, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 24px; + overflow: hidden; + display: flex; + flex-direction: column; + transition: border-color 0.25s ease, 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 { + background: rgba(22, 22, 22, 0.95); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; +} + +/* ─── Event Detail Page ──────────────────────────────────────────── */ +.event-detail-sidebar { + background: rgba(17, 17, 17, 0.92); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + padding: 2.25rem 1.75rem; + position: sticky; + top: 6.5rem; +} + +.event-detail-info-row { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 1rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.event-detail-info-row:last-child { + border-bottom: none; +} + +.event-detail-info-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #666; +} + +.event-detail-info-value { + font-size: 1.05rem; + font-weight: 700; + color: #fff; +} + +.agenda-item { + display: flex; + gap: 1.25rem; + position: relative; + padding-bottom: 1.5rem; +} + +.agenda-item::before { + content: ""; + position: absolute; + left: 10px; + top: 24px; + bottom: 0; + width: 1px; + background: rgba(255, 255, 255, 0.08); +} + +.agenda-item:last-child::before { + display: none; +} + +.agenda-dot { + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(249, 115, 22, 0.15); + border: 2px solid var(--fed-orange); + flex-shrink: 0; + margin-top: 2px; +} + +.speaker-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + background: rgba(249, 115, 22, 0.12); + border: 1.5px solid rgba(249, 115, 22, 0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + color: var(--fed-orange); + flex-shrink: 0; +} + +/* ─── Glassified Dimmed Orange Scrollbar ─────────────────────────── */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(249, 115, 22, 0.45) rgba(12, 12, 12, 0.4); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(8, 8, 8, 0.6); + backdrop-filter: blur(12px); +} + +::-webkit-scrollbar-thumb { + 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; +} + +::-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 ─────────────────────────────────────────────────── */ +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-up { + animation: fadeUp 0.6s ease forwards; +} + +.animate-delay-100 { + animation-delay: 0.1s; + opacity: 0; +} + +.animate-delay-200 { + animation-delay: 0.2s; + opacity: 0; +} + +.animate-delay-300 { + animation-delay: 0.3s; + opacity: 0; +} + +.animate-delay-400 { + animation-delay: 0.4s; + opacity: 0; +} + +.animate-delay-500 { + animation-delay: 0.5s; + opacity: 0; +} +/* ─── Signed-in avatar in the navbar ──────────────────────────────── + * Replaces the Login pill once a session is restored, matching what the + * previous navbar did. Sized to the pill's height so swapping between the + * two does not change the bar's layout. */ +.fed-avatar-link { + display: inline-flex; + align-items: center; + text-decoration: none; + border-radius: 9999px; + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.fed-avatar-link:hover { + transform: translateY(-1px); + box-shadow: 0 0 0 2px rgba(255, 85, 0, 0.55); +} + +.fed-avatar { + width: 2.35rem; + height: 2.35rem; + border-radius: 9999px; + object-fit: cover; + display: block; + border: 1px solid rgba(255, 255, 255, 0.18); + background: var(--fed-surface-2); +} + +.fed-avatar--sm { + width: 1.9rem; + height: 1.9rem; +} + +/* Mobile menu: avatar plus name, above the Logout button. */ +.fed-mobile-profile { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem 0 0.75rem; + color: var(--fed-text); + text-decoration: none; + font-weight: 600; +} + +/* Reserves the fixed navbar's height on every page that does not open with a + * full-bleed hero. Needs `!important` only because the `body, .page, main` + * rule above sets `margin-top: 0 !important`; declared after it so it wins. */ +.page--nav-offset { + margin-top: var(--fed-navbar-offset) !important; +} + +/* ─── Mobile form fields ─────────────────────────────────────────── */ + +/* Safari on iOS zooms the viewport in whenever a focused field renders its text + * below 16px, and never zooms back out. A global floor rather than a per + * component fix because the offenders are not all ours: react-select injects + * its own hidden text inputs at 12–13px, and focusing one when the dropdown + * opens was enough to magnify the whole sign-up page mid-form. + * + * Applies only below the tablet breakpoint, so the compact desktop styling of + * the admin forms is untouched. */ +@media (max-width: 768px) { + input, + select, + textarea { + font-size: 16px; + } +} diff --git a/app/globals.scss b/app/globals.scss index 04b15ee..4ebc7d0 100644 --- a/app/globals.scss +++ b/app/globals.scss @@ -1,4 +1,4 @@ -// Global stylesheet — ported verbatim from FED-Frontend/src/index.scss. +// Global stylesheet - ported verbatim from FED-Frontend/src/index.scss. // // Tailwind is deliberately NOT imported here. The original UI is built entirely // from SCSS modules, and Tailwind's preflight reset would subtly alter margins, @@ -11,7 +11,7 @@ // `@import url(...)` here. // // The original index.scss used @import. Under Next's CSS bundler those rules do -// not survive into the emitted chunk — verified at runtime: the page had zero +// not survive into the emitted chunk - verified at runtime: the page had zero // registered "Open Sans" font faces (against 183 in the Vite build), so all body // text silently fell back to a system sans-serif and every line box was ~2px // shorter than the original. @@ -22,9 +22,110 @@ // with react-calendar, and with Calendar.css missing the calendar opened as an // unstyled column of numbers. Verified by grepping the emitted CSS — zero // `.react-calendar` rules shipped. +@import "react-datepicker/dist/react-datepicker.css"; @import "react-date-picker/dist/DatePicker.css"; @import "react-calendar/dist/Calendar.css"; -@import "react-datepicker/dist/react-datepicker.css"; + +// Dark-theme overrides for the ported date pickers so calendars match the +// surface ramp instead of shipping as unstyled white widgets. +.react-date-picker { + width: 100%; +} + +.react-date-picker__wrapper { + border: none !important; + width: 100%; +} + +.react-date-picker__inputGroup { + min-width: 0; + padding: 0 0.25rem; +} + +.react-date-picker__inputGroup__input { + color: var(--text-primary) !important; +} + +.react-date-picker__inputGroup__divider, +.react-date-picker__inputGroup__leadingZero { + color: var(--text-secondary); +} + +.react-date-picker__calendar { + z-index: 1300 !important; + inset: calc(100% + 6px) auto auto 0 !important; +} + +.react-calendar { + border: 1px solid var(--border-strong) !important; + border-radius: var(--radius-md); + background: var(--surface-2) !important; + color: var(--text-primary); + box-shadow: var(--depth); + font-family: var(--font-sans); +} + +.react-calendar__navigation button, +.react-calendar__tile { + color: var(--text-primary); +} + +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus, +.react-calendar__tile:enabled:hover, +.react-calendar__tile:enabled:focus { + background: var(--surface-3) !important; +} + +.react-calendar__tile--now { + background: var(--accent-quiet) !important; +} + +.react-calendar__tile--active { + background: var(--accent) !important; + color: #fff !important; +} + +.react-datepicker { + font-family: var(--font-sans); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--text-primary); +} + +.react-datepicker__header { + background: var(--surface-3); + border-bottom: 1px solid var(--border); +} + +.react-datepicker__current-month, +.react-datepicker-time__header, +.react-datepicker__day-name, +.react-datepicker__day, +.react-datepicker__time-name, +.react-datepicker__time-list-item { + color: var(--text-primary); +} + +.react-datepicker__day:hover, +.react-datepicker__time-list-item:hover { + background: var(--surface-3) !important; +} + +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected, +.react-datepicker__time-list-item--selected { + background: var(--accent) !important; + color: #fff !important; +} + +.react-datepicker__time-container, +.react-datepicker__time, +.react-datepicker__time-box { + background: var(--surface-2); + border-color: var(--border); +} // The brand gradient. Lived in Global.scss on the original site; it moved here // because Global.scss is now injected into every CSS Module, and a `:root` @@ -35,17 +136,118 @@ rgba(255, 190, 11, 0.84) -29.7%, rgba(244, 43, 3, 0.84) 128.34% ); + + // --------------------------------------------------------------------------- + // Design tokens + // + // The surface ramp is intentionally shallow - on a pure black page, depth is + // carried by hairline borders rather than by lifting panels toward grey, which + // is what makes stacked cards read as separate objects without any shadow or + // glow. + // --------------------------------------------------------------------------- + --surface-0: #000000; + --surface-1: #0a0a0a; + --surface-2: #121212; + --surface-3: #1a1a1a; + + --border: rgba(255, 255, 255, 0.09); + --border-strong: rgba(255, 255, 255, 0.16); + --border-focus: #ff8a00; + + // Contrast on #000: primary 20:1, secondary 8.6:1, tertiary 5.3:1. Tertiary is + // reserved for non-essential metadata so every ratio clears WCAG AA. + --text-primary: #f7f7f7; + --text-secondary: #a6a6a6; + --text-tertiary: #7a7a7a; + --text-inverse: #0a0a0a; + + --accent: #ff8a00; + --accent-hover: #ff9d2b; + --accent-quiet: rgba(255, 138, 0, 0.12); + + --positive: #3fbf67; + --negative: #f4523b; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-pill: 999px; + + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-display: "Space Grotesk", "Inter", system-ui, sans-serif; + + --ease: cubic-bezier(0.2, 0, 0, 1); + --dur: 140ms; + + // A top highlight and a bottom shade read as a physical bevel, so controls + // gain depth from lighting alone - no glow, and no colour of their own, which + // is what lets the same two lines sit on an accent button and a black card. + --depth: inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.35); + --depth-strong: inset 0 1px 0 rgba(255, 255, 255, 0.14), + inset 0 -1px 0 rgba(0, 0, 0, 0.45); +} + +// One ring definition for every interactive element on the redesigned surfaces. +// :focus-visible rather than :focus so a pointer press never paints a ring. +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } * { margin: 0px; padding: 0px; box-sizing: border-box; - font-family: "Open Sans", sans-serif; + font-family: var(--font-sans); } -.react-date-picker__wrapper { - border: none !important; +// Headings take the display face everywhere unless a section overrides it, so +// the change reaches pages that were never part of the redesign. +h1, +h2, +h3, +h4 { + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +/** + * Lifted out of TeamCard.module.scss, where it sits as a top-level bare + * `button { }` rule. + * + * Vite emits every CSS Module into one stylesheet for the whole SPA, and it does + * not hash element selectors — so that rule was live on every page of the + * original site. Next code-splits CSS per route, so once ported it only loaded + * where TeamCard did (/Team, /profile/members). Everywhere else buttons fell + * back to the user-agent appearance: measured on /otp, the disabled "Resend OTP" + * button rendered with `background-color: rgba(19, 1, 1, 0.3)` and a + * `2px outset` border against the original's transparent / `0px none`. + * + * Declared here so the cascade matches the original app. It is only specificity + * 0-0-1, so every component's own class rules still win, exactly as before. + */ +button { + color: #ff8a00; + background-color: transparent; + border: none; + cursor: pointer; + margin-top: 10px; + font-size: 1.2em; + font-weight: 500; + margin-bottom: 15px; } /** @@ -75,13 +277,21 @@ button { } html { - scroll-behavior: smooth; + // Was `scroll-behavior: smooth`. Removed: it hijacks every in-page jump and + // App Router route change, which Next also warns about. + background-color: $page-color; } body { background-color: $page-color; color: $text-color; - overflow-x: hidden; + // `clip` rather than `hidden`: `overflow-x: hidden` forces `overflow-y` to + // compute to `auto`, which makes a scroll container and is the usual + // reason `position: sticky` silently stops working further down the tree. + // `clip` suppresses the same sideways overflow without that side effect. + overflow-x: clip; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } .page { @@ -90,8 +300,16 @@ body { justify-content: center; align-items: center; background-color: $page-color; - overflow: hidden; - margin-top: 90px; + // `overflow-x: clip`, not `overflow: hidden`. + // + // The intent has always been to stop sideways overflow, but `hidden` makes + // this element a scroll container, and a scroll container between a sticky + // element and the viewport stops it sticking. That is why the About + // section's pin had to be reimplemented in JavaScript. `clip` trims the same + // overflow without creating a scroll container, so `position: sticky` works + // normally inside the page. + overflow-x: clip; + margin-top: 88px; } .page.omega-page { @@ -117,17 +335,21 @@ body { body::-webkit-scrollbar { width: 10px; height: 0px; - transition: 0.2s linear; - background-color: #292929; + background-color: transparent; } body::-webkit-scrollbar-thumb { - border-radius: 22px; - background: var(--primary); + border-radius: var(--radius-pill); + border: 3px solid transparent; + background-clip: content-box; + background-color: rgba(255, 255, 255, 0.16); +} + +body::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.28); } body::-webkit-scrollbar-track { - padding: 0px 20px; background-color: transparent; } diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..bded931 Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx index 23caba4..1f2ddc0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,9 @@ import { SITE_URL } from "@/lib/seo/metadata"; import { organizationSchema, websiteSchema } from "@/lib/seo/structured-data"; import "./globals.scss"; +// Design tokens and `fed-*` component classes for the revamped Navbar and Home +// sections. Loaded after globals.scss so its :root tokens win. +import "./globals.css"; /** * Root layout. @@ -43,7 +46,11 @@ export const metadata: Metadata = { title: "FED KIIT", description: SITE.description, }, - icons: { icon: "/favicon.ico" }, + // No `icons` entry on purpose. Setting one overrides Next's file convention + // wholesale, and this used to pin the icon to /favicon.ico — which was still + // the Vercel triangle that `create-next-app` ships, so the scaffolding icon + // was being served in place of the FED logo. Leaving it out lets app/ + // favicon.ico, icon.png and apple-icon.png all get advertised. other: { "facebook-domain-verification": "j4kyebnva8sowmo539jn3julvtgvqq", }, diff --git a/app/not-found.tsx b/app/not-found.tsx index 0155f56..3708bd0 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,4 +1,4 @@ -import Navbar from "@/src/layouts/Navbar/Navbar"; +import Navbar from "@/app/components/Navbar"; import Footer from "@/src/layouts/Footer/Footer"; import ErrorPage from "@/src/views/Error/Error"; diff --git a/lib/config/images.ts b/lib/config/images.ts index 7ccc947..450943d 100644 --- a/lib/config/images.ts +++ b/lib/config/images.ts @@ -24,16 +24,39 @@ * ratio, and a cropped QR code does not scan. */ export const IMAGE_SIZES = { - /** Event cover art on the form/event card. */ + /** + * Event poster on the event card. + * + * These were `196.37 x 350.67`, carried over from the Express controllers. + * Cloudinary takes integer pixel dimensions, so the fractional pair meant the + * transformation was never applied at all — banners were stored at whatever + * size they were uploaded. One live poster is a 4320x4320 PNG weighing 3.9 MB, + * served in full to every visitor of /Events. + * + * 1600 is the cap now: comfortably sharp for the featured card at 2x DPI, and + * roughly a tenth of the bytes. Square because posters here are square and + * `limit` preserves aspect ratio anyway — the box only sets an upper bound. + * + * Only affects new uploads. Images already in Cloudinary keep their size. + */ FormImages: { - width: 196.37, - height: 350.67, + width: 1600, + height: 1600, }, /** Payment QR shown on the registration form. */ QRMediaImages: { width: 400, height: 150, }, + /** + * Payment proof a participant uploads on the registration form. Kept large: + * an admin has to be able to read a UTR and an amount off it, and a UPI + * confirmation screenshot is a tall phone capture. + */ + PaymentScreenshots: { + width: 1200, + height: 1600, + }, /** Blog hero image. */ BlogImages: { width: 1200, diff --git a/lib/services/attendance.ts b/lib/services/attendance.ts index 2d33cd4..a9b0be1 100644 --- a/lib/services/attendance.ts +++ b/lib/services/attendance.ts @@ -174,6 +174,46 @@ export async function markAttendance(input: { return { message: "Attendance marked successfully.", attendance: updated }; } +export type StoredField = { name?: string; type?: string; value?: unknown }; +export type StoredSubmission = { + user_name?: string; + user_email?: string; + date_time?: string; + amount?: string; + sections?: Array<{ name?: string; fields?: StoredField[] }>; +}; + +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; +} { + const fields = (submission.sections ?? []).flatMap((section) => + Array.isArray(section?.fields) ? section.fields : [], + ); + + 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), + ); + + return { + utr: utr?.value == null ? "" : String(utr.value), + screenshot: isHttpUrl(screenshot?.value) ? screenshot.value : null, + }; +} + /** Flattens a registration's stored submission into spreadsheet columns. */ function flattenRegistration(row: { teamName: string; @@ -263,8 +303,30 @@ export async function exportAttendance(formId: string) { }); 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 }, + }); + const paymentByUser = new Map< + string, + { utr: string; screenshot: string | null } + >(); + for (const registration of registrations) { + for (const entry of registration.value ?? []) { + const payment = paymentFromSubmission(entry as StoredSubmission); + if (payment.utr || payment.screenshot) { + paymentByUser.set(registration.userId, payment); + break; + } + } + } + const rows = records.map((r) => { const u = byId.get(r.userId); + const payment = paymentByUser.get(r.userId); return { name: u?.name ?? "", email: u?.email ?? "", @@ -274,6 +336,8 @@ export async function exportAttendance(formId: string) { isPresent: r.isPresent ? "YES" : "NO", isPaymentVerified: r.isPaymentVerified ? "YES" : "NO", markedAt: r.markedAt ? r.markedAt.toISOString() : "", + utr: payment?.utr ?? "", + paymentScreenshot: payment?.screenshot ?? "", }; }); diff --git a/lib/types/event.ts b/lib/types/event.ts index adc6d32..423b867 100644 --- a/lib/types/event.ts +++ b/lib/types/event.ts @@ -29,7 +29,22 @@ export type EventInfo = { isRegistrationClosed?: boolean; isEventPast?: boolean; ongoingEvent?: boolean; - receiverDetails?: { upi?: string | null; media?: string | null }; + receiverDetails?: { + upi?: string | null; + media?: string | null; + /** + * How the participant is asked to pay. Absent on every event created before + * this setting existed, and those all stored UPI/QR — so a missing `mode` + * has to be read as "QR" everywhere, never as "unset". + */ + mode?: "QR" | "Link" | null; + /** External payment page, used only when `mode` is "Link". */ + link?: string | null; + /** Label for the button that opens `link`. */ + buttonText?: string | null; + /** Optional copy rendered under that button. */ + message?: string | null; + }; }; /** Normalised event, safe to render. Every consumer uses this, not raw `info`. */ diff --git a/public/assets/design-2.png b/public/assets/design-2.png new file mode 100644 index 0000000..06049c4 Binary files /dev/null and b/public/assets/design-2.png differ diff --git a/public/assets/design-3.png b/public/assets/design-3.png new file mode 100644 index 0000000..a90c503 Binary files /dev/null and b/public/assets/design-3.png differ diff --git a/public/assets/design-4.png b/public/assets/design-4.png new file mode 100644 index 0000000..d730270 Binary files /dev/null and b/public/assets/design-4.png differ diff --git a/public/assets/design.png b/public/assets/design.png new file mode 100644 index 0000000..d5a6d93 Binary files /dev/null and b/public/assets/design.png differ diff --git a/public/assets/fedrick.png b/public/assets/fedrick.png new file mode 100644 index 0000000..c138763 Binary files /dev/null and b/public/assets/fedrick.png differ diff --git a/public/contact-envelope.png b/public/contact-envelope.png new file mode 100644 index 0000000..ae20fdb Binary files /dev/null and b/public/contact-envelope.png differ diff --git a/public/fedkiit-logo.png b/public/fedkiit-logo.png new file mode 100644 index 0000000..e67b65d Binary files /dev/null and b/public/fedkiit-logo.png differ diff --git a/public/fedkiit-logo.svg b/public/fedkiit-logo.svg new file mode 100644 index 0000000..0b78032 --- /dev/null +++ b/public/fedkiit-logo.svg @@ -0,0 +1,4 @@ + + + F + diff --git a/public/fedkiit-mascot.png b/public/fedkiit-mascot.png new file mode 100644 index 0000000..e67b65d Binary files /dev/null and b/public/fedkiit-mascot.png differ diff --git a/public/grid-bg.png b/public/grid-bg.png new file mode 100644 index 0000000..c7bddf9 Binary files /dev/null and b/public/grid-bg.png differ diff --git a/public/to-fed-envelope-clean-transparent-fixed.png b/public/to-fed-envelope-clean-transparent-fixed.png new file mode 100644 index 0000000..1e7dc2c Binary files /dev/null and b/public/to-fed-envelope-clean-transparent-fixed.png differ diff --git a/public/to-fed-envelope-clean-transparent.png b/public/to-fed-envelope-clean-transparent.png new file mode 100644 index 0000000..cb58f1d Binary files /dev/null and b/public/to-fed-envelope-clean-transparent.png differ diff --git a/public/to-fed-envelope-fixed.png b/public/to-fed-envelope-fixed.png new file mode 100644 index 0000000..b561283 Binary files /dev/null and b/public/to-fed-envelope-fixed.png differ diff --git a/public/to-fed-envelope-seamless.png b/public/to-fed-envelope-seamless.png new file mode 100644 index 0000000..9c93b94 Binary files /dev/null and b/public/to-fed-envelope-seamless.png differ diff --git a/public/to-fed-envelope-transparent-fixed.png b/public/to-fed-envelope-transparent-fixed.png new file mode 100644 index 0000000..0e8661f Binary files /dev/null and b/public/to-fed-envelope-transparent-fixed.png differ diff --git a/public/to-fed-envelope-transparent.png b/public/to-fed-envelope-transparent.png new file mode 100644 index 0000000..cb58f1d Binary files /dev/null and b/public/to-fed-envelope-transparent.png differ diff --git a/public/to-fed-envelope.png b/public/to-fed-envelope.png new file mode 100644 index 0000000..65f2a81 Binary files /dev/null and b/public/to-fed-envelope.png differ diff --git a/scripts/audit-nesting.mjs b/scripts/audit-nesting.mjs index 71efc9c..590a99c 100644 --- a/scripts/audit-nesting.mjs +++ b/scripts/audit-nesting.mjs @@ -57,6 +57,9 @@ const stripLiterals = (s) => s // `<` and `>` are deliberately absent: with them, the `/` of a closing tag // like `` reads as the start of a regex and eats the tags after it. + // Character classes are kept disjoint so the alternation cannot match the + // same text two ways — CodeQL flagged the earlier form as a polynomial + // ReDoS. Upstream autofix, kept verbatim. .replace(/(^|[=(,:[!&|?{};\n])(\s*)\/(?![/*])((?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\[\n])+)\/[gimsuyv]*/g, (m, pre, ws, body) => pre + ws + blank("/" + body + "/")) .replace(/'(?:\\.|[^'\\\n])*'/g, blank) diff --git a/src/assets/images/contact.png b/src/assets/images/contact.png index d770886..ae20fdb 100644 Binary files a/src/assets/images/contact.png and b/src/assets/images/contact.png differ diff --git a/src/assets/styles/Global.scss b/src/assets/styles/Global.scss index 4c7e4f7..fb70963 100644 --- a/src/assets/styles/Global.scss +++ b/src/assets/styles/Global.scss @@ -1,4 +1,4 @@ -// Global SCSS variables — ported from FED-Frontend/src/assets/styles/Global.scss. +// Global SCSS variables - ported from FED-Frontend/src/assets/styles/Global.scss. // // Values are byte-for-byte the originals, including the duplicated // `$text-color` (declared `green`, then immediately overwritten with `white`) @@ -7,9 +7,9 @@ // The `:root { --primary }` block that lived here has moved to app/globals.scss. // This file is injected into every *.module.scss through // `sassOptions.additionalData`, and a `:root` rule inside a CSS Module is an -// impure selector that Next.js rejects — it also only ever needed emitting once. +// impure selector that Next.js rejects - it also only ever needed emitting once. -$page-color: #1c1c1c; +$page-color: #000000; $text-color: green; //EventCard @@ -19,3 +19,8 @@ $back-glass: rgba(255, 255, 255, 0.1); $text-color: white; //EventCardModal $bg-color: rgb(46, 44, 44); + +// The redesigned surfaces read their colours from the `:root` custom properties +// in app/globals.scss rather than from variables here - this file is only +// `@use`d by the modules that opted into it, so a var() reaches everywhere a +// SCSS variable cannot. diff --git a/src/authentication/Login/GoogleLogin.jsx b/src/authentication/Login/GoogleLogin.jsx index aace2dc..7c2c009 100644 --- a/src/authentication/Login/GoogleLogin.jsx +++ b/src/authentication/Login/GoogleLogin.jsx @@ -5,8 +5,7 @@ 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 users from "../../data/user.json"; -import AuthContext from "../../context/AuthContext"; +import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import google from "../../assets/images/google.png"; import { Alert, MicroLoading } from "../../microInteraction"; import { api } from "../../services"; @@ -90,7 +89,7 @@ export default function GoogleLogin() { user.regForm, user.blurhash, response.data.token, - 9600000 + SESSION_TTL_MS ); // App.jsx re-rendered /Login as once isLoggedIn // flipped; nothing watches that flag under the App Router, so the diff --git a/src/authentication/Login/Login.jsx b/src/authentication/Login/Login.jsx index 3d784e7..b0f9a71 100644 --- a/src/authentication/Login/Login.jsx +++ b/src/authentication/Login/Login.jsx @@ -8,9 +8,8 @@ import style from "./styles/Login.module.scss"; import Input from "../../components/Core/Input"; import Button from "../../components/Core/Button"; import Text from "../../components/Core/Text"; -import users from "../../data/user.json"; import { api } from "../../services"; -import AuthContext from "../../context/AuthContext"; +import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import { RecoveryContext } from "../../context/RecoveryContext"; import GoogleLogin from "./GoogleLogin"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; @@ -105,7 +104,7 @@ const Login = () => { user.regForm, user.blurhash, response.data.token, - 9600000 + SESSION_TTL_MS ); // App.jsx re-rendered /Login as the moment // isLoggedIn flipped. App Router routes are files and nothing watches diff --git a/src/authentication/SignUp/CompleteProfile.jsx b/src/authentication/SignUp/CompleteProfile.jsx index c29cf57..179bc30 100644 --- a/src/authentication/SignUp/CompleteProfile.jsx +++ b/src/authentication/SignUp/CompleteProfile.jsx @@ -8,7 +8,7 @@ import Button from "../../components/Core/Button"; import Load from "../../microInteraction/Load/Load"; import styles from "./style/CompleteProfile.module.scss"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import AuthContext from "../../context/AuthContext"; +import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import { Alert, MicroLoading } from "../../microInteraction"; import { api } from "../../services"; import { useRouter, usePathname } from "next/navigation"; @@ -115,7 +115,7 @@ function CompleteProfile() { userObject.editProfileCount, "USER", "someToken", - 7200000 + SESSION_TTL_MS ); // Honours a pending destination — a team invite link, typically — // falling back to "/" as the original always did. diff --git a/src/authentication/SignUp/GoogleSignup.jsx b/src/authentication/SignUp/GoogleSignup.jsx index 3f72a04..aa765fb 100644 --- a/src/authentication/SignUp/GoogleSignup.jsx +++ b/src/authentication/SignUp/GoogleSignup.jsx @@ -6,9 +6,8 @@ import styles from "./style/Signup.module.scss"; import { useGoogleLogin } from "@react-oauth/google"; import axios from "axios"; -import AuthContext from "../../context/AuthContext"; +import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import google from "../../assets/images/google.png"; -import users from "../../data/user.json"; import { Alert, MicroLoading } from "../../microInteraction"; import { api } from "../../services"; import { useRouter } from "next/navigation"; @@ -66,10 +65,10 @@ export default function GoogleSignup({ setAlert }) { position: "bottom-right", duration: 3000, }); - sessionStorage.removeItem("prevPage"); // Clean up - // Order matters: App.jsx cleared prevPage here and only then rendered - // , which therefore fell through to /profile. - setNavigatePath(postAuthRedirect()); + // `prevPage` is deliberately *not* cleared first. The original wiped + // it here, so a Google sign-up begun from a team invite link lost the + // invite and landed on the profile page instead of joining. + setNavigatePath(postAuthRedirect("/")); setTimeout(() => { localStorage.setItem("token",response.data.token); @@ -90,7 +89,7 @@ export default function GoogleSignup({ setAlert }) { user.regForm, user.blurhash, response.data.token, - 9600000 + SESSION_TTL_MS ); // App.jsx re-rendered /SignUp as once isLoggedIn // flipped; nothing watches that flag under the App Router, so the diff --git a/src/authentication/SignUp/SignUP.jsx b/src/authentication/SignUp/SignUP.jsx index 5c87a08..2148755 100644 --- a/src/authentication/SignUp/SignUP.jsx +++ b/src/authentication/SignUp/SignUP.jsx @@ -15,7 +15,7 @@ import OtpInputModal from "../../features/Modals/authentication/OtpInputModal"; import { Alert, MicroLoading } from "../../microInteraction"; import { RecoveryContext } from "../../context/RecoveryContext"; import { api } from "../../services"; -import AuthContext from "../../context/AuthContext"; +import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import Link from "next/link"; import { useRouter } from "next/navigation"; import postAuthRedirect from "../../utils/postAuthRedirect"; @@ -254,7 +254,7 @@ const SignUp = () => { response.data.user.regForm, response.data.user.blurhash, response.data.token, - 10800000 + SESSION_TTL_MS ); // console.log(authCtx); // Return to whatever sent them here — a team invite link, typically — @@ -323,9 +323,9 @@ const SignUp = () => { margin: "8 px 0 4px 0", }} > -
+

or

-
+
@@ -338,7 +338,6 @@ const SignUp = () => { onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} - className={styles.input} />
@@ -350,7 +349,6 @@ const SignUp = () => { onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} - className={styles.input} />
@@ -361,7 +359,6 @@ const SignUp = () => { placeholder="eg.-myemail@gmail.com" label="Email" name="email" - className={styles.input} onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} @@ -376,7 +373,6 @@ const SignUp = () => { onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} - className={styles.input} />
@@ -394,7 +390,6 @@ const SignUp = () => { placeholder="College Name" label="college" name="college" - className={styles.input} value={showUser.college} onChange={(e) => DataInp(e.target.name, e.target.value)} required @@ -417,7 +412,6 @@ const SignUp = () => { placeholder="School" label="School" name="school" - className={styles.input} onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} @@ -445,7 +439,6 @@ const SignUp = () => { onChange={(value) => DataInp("year", value)} required style={{ width: "96%" }} - className={styles.input} />
@@ -457,7 +450,6 @@ const SignUp = () => { onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "96%" }} - className={styles.input} />
@@ -469,7 +461,6 @@ const SignUp = () => { onChange={(e) => DataInp(e.target.name, e.target.value)} required style={{ width: "98%" }} - className={styles.input} />
{ 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 ( + +