diff --git a/app/api/certificate/sendCertificatesAndEvents/route.ts b/app/api/certificate/sendCertificatesAndEvents/route.ts index 97917a8..d55b25f 100644 --- a/app/api/certificate/sendCertificatesAndEvents/route.ts +++ b/app/api/certificate/sendCertificatesAndEvents/route.ts @@ -1,36 +1,50 @@ -import { sendCertificatesAndEvents } from "@/lib/services/certificates"; +import { prisma } from "@/lib/db"; import { body, expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser, isAdmin } from "@/lib/auth/access"; /** * POST /api/certificate/sendCertificatesAndEvents - * Port of controllers/certificate/eventCertificateController.js — admin only. + * Port of controllers/certificate/eventCertificateController.js. * - * Recipients who already hold a certificate for the event are skipped rather - * than issued a duplicate, so re-running after a partial failure is safe. + * The name is misleading and comes from the Express backend: this sends + * nothing. It looks up the certificates already issued to one email address and + * returns each one alongside the event it belongs to, which is what the "My + * Events" tab uses to put a View link on each past event. + * + * It was previously implemented here as the certificate *issuing* endpoint — + * admin-only, taking `{ eventId, recipients }` and returning `{ data }`. Nothing + * called it that way; the only caller is the participant profile, which sends + * `{ email }` and reads `certandevent`. So every participant got a 403, their + * certificate list stayed empty, and the View button had no link to follow. + * + * Access: you may read your own certificates; admins may read anyone's. The + * Express route had no auth middleware at all, which let anyone enumerate + * another member's certificates by guessing their address. */ 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<{ - eventId?: string; - recipients?: Array<{ email: string; fieldValues?: Record }>; - emails?: string[]; - }>(request); + const b = await body<{ email?: string }>(request); + const email = (b.email ?? "").toLowerCase().trim(); + if (!email) return expressError(400, "Email is required"); - // Accepts either a rich recipient list or a plain array of addresses. - const recipients = - b.recipients ?? - (b.emails ?? []).map((email) => ({ email, fieldValues: {} })); + if (email !== user.email.toLowerCase() && !isAdmin(user)) { + return expressError(403, "Unauthorized"); + } - const data = await sendCertificatesAndEvents({ - eventId: b.eventId ?? "", - recipients, + const issued = await prisma.issuedCertificates.findMany({ + where: { email }, + include: { event: true }, }); - return json({ success: true, message: "Certificates processed", data }); + // Shape kept identical to Express: [{ cert, event }], with rows whose event + // has since been deleted dropped rather than returned as null. + const certandevent = issued + .filter((row) => row.event) + .map(({ event, ...cert }) => ({ cert, event })); + + return json({ certandevent }); }); } diff --git a/src/authentication/Login/Login.jsx b/src/authentication/Login/Login.jsx index a28db69..e6c05c5 100644 --- a/src/authentication/Login/Login.jsx +++ b/src/authentication/Login/Login.jsx @@ -143,7 +143,7 @@ const Login = () => { }; return ( -
+
diff --git a/src/authentication/Login/styles/Login.module.scss b/src/authentication/Login/styles/Login.module.scss index 285fb10..e114054 100644 --- a/src/authentication/Login/styles/Login.module.scss +++ b/src/authentication/Login/styles/Login.module.scss @@ -1,3 +1,16 @@ +// The page shell inside `.authpage`. +// +// `.authpage` is a column flex container with `align-items: center`, so its +// child is sized shrink-to-fit on the cross axis. This wrapper had no width of +// its own, collapsed to its content, and `.container`'s `width: 100%` then +// resolved against that — the card rendered ~257px wide on a laptop and never +// reached its 480px max-width. +.page { + width: 100%; + display: flex; + justify-content: center; +} + .container { position: relative; z-index: 10; diff --git a/src/components/EventCard/EventCard.jsx b/src/components/EventCard/EventCard.jsx index d3399fc..4183cdc 100644 --- a/src/components/EventCard/EventCard.jsx +++ b/src/components/EventCard/EventCard.jsx @@ -15,13 +15,14 @@ import { Blurhash } from "react-blurhash"; import { Alert, MicroLoading } from "../../microInteraction"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { isPrerequisiteMet } from "../../utils/prerequisite"; +import { cdn } from "../../utils/cloudinary"; const EventCard = (props) => { const { data, onOpen, type, - modalpath, showShareButton = true, showRegisterButton = true, additionalContent, @@ -29,7 +30,6 @@ const EventCard = (props) => { onDelete, enableEdit, isLoading, - isRegisteredInRelatedEvents, eventName, variant = "default", } = props; @@ -38,7 +38,6 @@ const EventCard = (props) => { const authCtx = useContext(AuthContext); const [isOpen, setOpen] = useState(false); const [isQRModalOpen, setQRModalOpen] = useState(false); - const [isHovered, setisHovered] = useState(false); const [remainingTime, setRemainingTime] = useState(""); const [btnTxt, setBtnTxt] = useState("Register Now"); const router = useRouter(); @@ -97,6 +96,10 @@ const EventCard = (props) => { } }; + // Per-event, not per-page: this card's own prerequisite against this + // visitor's own registrations. + const prerequisiteMet = isPrerequisiteMet(info, authCtx.user?.regForm); + const dayWithSuffix = day + getOrdinalSuffix(day); const month = date.toLocaleDateString("en-GB", { month: "long" }); const year = date.getFullYear(); @@ -162,54 +165,33 @@ const EventCard = (props) => { }, [info.isRegistrationClosed, remainingTime]); useEffect(() => { - if (authCtx.isLoggedIn && authCtx.user.regForm) { - if (isRegisteredInRelatedEvents) { - if (data?.info?.relatedEvent === "null") { - if (authCtx.user.regForm.includes(data.id)) { - setBtnTxt("Already Registered"); - } - } else { - if (authCtx.user.regForm.includes(data.id)) { - setBtnTxt("Already Registered"); - } else { - if (remainingTime) { - setBtnTxt(remainingTime); - } else if (data?.info?.isRegistrationClosed) { - setBtnTxt("Closed"); - } else { - setBtnTxt("Register Now"); - } - } - } - } else { - if (data?.info?.relatedEvent === "null") { - if (authCtx.user.regForm.includes(data.id)) { - setBtnTxt("Already Registered"); - } else { - if (remainingTime) { - setBtnTxt(remainingTime); - } else if (data?.info?.isRegistrationClosed) { - setBtnTxt("Closed"); - } else { - setBtnTxt("Register Now"); - } - } - } else { - if (authCtx.user.access === "USER") { - if (data?.info?.isRegistrationClosed) { - setBtnTxt("Closed"); - } else { - setBtnTxt("Locked"); - } - } - } - } + if (!authCtx.isLoggedIn || !authCtx.user.regForm) return; + + const openState = () => { + if (remainingTime) return remainingTime; + if (data?.info?.isRegistrationClosed) return "Closed"; + return "Register Now"; + }; + + if (authCtx.user.regForm.includes(data.id)) { + setBtnTxt("Already Registered"); + return; } + + // Locked until this event's own prerequisite is met. Admins are exempt so + // they can still open a gated form to check it. + if (!prerequisiteMet && authCtx.user.access === "USER") { + setBtnTxt(data?.info?.isRegistrationClosed ? "Closed" : "Locked"); + return; + } + + setBtnTxt(openState()); }, [ authCtx.isLoggedIn, authCtx.user.regForm, + authCtx.user.access, data, - isRegisteredInRelatedEvents, + prerequisiteMet, remainingTime, ]); @@ -264,7 +246,7 @@ const EventCard = (props) => { ) { setAlert({ type: "info", - message: `You need to register for ${eventName} first`, + message: `You need to register for ${eventName || "the required event"} first`, position: "bottom-right", duration: 3000, }); @@ -324,7 +306,16 @@ const EventCard = (props) => { } }; - const detailsHref = modalpath + data.id; + // Every event -- upcoming, past, or viewed from the admin panel -- is served + // by the single /Events/[eventId] route, so the card builds its own link + // rather than trusting the caller. + // + // This used to be `modalpath + data.id`, from when `modalpath` named a modal + // and was never navigated to. Turning the title into a real made those + // strings live URLs, and three of the four callers were passing paths that + // have no route: "/pastEvents/", "/Events/pastEvents/" and "/profile/Events/". + // Every past-event card on the site 404'd as a result. + const detailsHref = `/Events/${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 = @@ -423,8 +414,6 @@ const EventCard = (props) => { return (
setisHovered(true)} - onMouseLeave={() => setisHovered(false)} > {variant === "featured" && (
- {enableEdit && isHovered && authCtx.user.access === "ADMIN" && ( + {/* Always rendered. It used to be gated on an `isHovered` state, which no + touch device ever sets — so admins on a phone could not reach Edit, + Delete or Analytics at all. The fade-in on hover now lives in CSS, + behind `@media (hover: hover)`, so pointer devices keep the reveal and + touch devices simply always see the bar. */} + {enableEdit && authCtx.user.access === "ADMIN" && (
); diff --git a/src/features/Modals/Event/LiveEventPopup/LiveEventPopup.jsx b/src/features/Modals/Event/LiveEventPopup/LiveEventPopup.jsx index 852ef90..21d1e14 100644 --- a/src/features/Modals/Event/LiveEventPopup/LiveEventPopup.jsx +++ b/src/features/Modals/Event/LiveEventPopup/LiveEventPopup.jsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import axios from "axios"; import styles from "./styles/LiveEventPopup.module.scss"; import { api } from "../../../../services"; +import { cdn } from "../../../../utils/cloudinary"; const LiveEventPopup = () => { const [isVisible, setIsVisible] = useState(false); @@ -76,7 +77,7 @@ const LiveEventPopup = () => { × - Event + Event
diff --git a/src/sections/Home/Events/EventsSection.jsx b/src/sections/Home/Events/EventsSection.jsx index 3ebc1c0..c7524a6 100644 --- a/src/sections/Home/Events/EventsSection.jsx +++ b/src/sections/Home/Events/EventsSection.jsx @@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react"; import Link from "next/link"; import { api } from "../../../services"; import styles from "./styles/EventsSection.module.scss"; +import { cdn } from "../../../utils/cloudinary"; /** Shown when an event carries no image of its own. */ const EVENT_BANNER_FALLBACK = "/fedkiit-logo.png"; @@ -135,7 +136,7 @@ export default function HomeEventsSection() {
{/* Top Image Banner with Tag Overlay */}
- {ev.title} + {ev.title} {ev.tag}
diff --git a/src/sections/Home/Hero/Hero.jsx b/src/sections/Home/Hero/Hero.jsx index 20be52f..1a64a15 100644 --- a/src/sections/Home/Hero/Hero.jsx +++ b/src/sections/Home/Hero/Hero.jsx @@ -5,6 +5,7 @@ import styles from "./styles/Hero.module.scss"; import CarouselImg from "../../../data/Carousel.json"; import HeroGallery from "../../../components/HeroGallery/HeroGallery"; import { AnimatedBox } from "../../../assets/animations/AnimatedBox"; +import { cdn } from "../../../utils/cloudinary"; const titles = [ "Entrepreneurship.", @@ -82,7 +83,7 @@ function Hero() {
diff --git a/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx b/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx index 757eada..efd7e3c 100644 --- a/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx +++ b/src/sections/Profile/Admin/Form/NewForm/NewForm.jsx @@ -136,13 +136,37 @@ function NewForm() { useEffect(() => { if (authCtx.eventData) { + const info = authCtx.eventData?.info ?? {}; + const storedSections = authCtx.eventData?.sections ?? []; + setdata({ - ...authCtx.eventData?.info, - isPublic: authCtx.eventData?.info.isPublic, - isRegistrationClosed: authCtx.eventData?.info.isRegistrationClosed, - isEventPast: authCtx.eventData?.info.isEventPast, + ...info, + isPublic: info.isPublic, + isRegistrationClosed: info.isRegistrationClosed, + isEventPast: info.isEventPast, }); - setsections(authCtx.eventData?.sections); + setsections(storedSections); + + // Restore the payment step as well. + // + // `constructForPreview` always drops the stored "Payment Details" section + // and re-adds `paymentSection` in its place, so that the step reflects the + // current QR/Link settings. But `paymentSection` starts out null and + // nothing here used to set it, so editing a paid event dropped the stored + // step and put nothing back: the Pay Now button, the UTR field and the + // screenshot upload all disappeared from the live form on save. + // + // The stored section's `_id` is carried over so the earlier sections' + // `onNext` pointers still resolve to it. + if (info.eventType === "Paid") { + const stored = storedSections.find( + (section) => section?.name === "Payment Details" + ); + setpaymentSection( + buildPaymentSection(info.receiverDetails?.mode || "QR", stored?._id) + ); + } + setisEditing(true); } }, []); diff --git a/src/sections/Profile/Admin/View/ViewEvent/VIewEvent.jsx b/src/sections/Profile/Admin/View/ViewEvent/VIewEvent.jsx index 3b3cf2e..8897727 100644 --- a/src/sections/Profile/Admin/View/ViewEvent/VIewEvent.jsx +++ b/src/sections/Profile/Admin/View/ViewEvent/VIewEvent.jsx @@ -156,7 +156,6 @@ function ViewEvent({ handleChangePage }) { data={event} customStyles={customStyles} type="ongoing" - modalpath="/profile/Events/" isPastpage={true} aosDisable={true} onDelete={handleDeleteEvent} @@ -188,7 +187,6 @@ function ViewEvent({ handleChangePage }) { data={event} customStyles={customStyles} type="past" - modalpath="/profile/Events/" isPastpage={true} aosDisable={true} onEdit={() => router.push("/profile/Form")} diff --git a/src/sections/Profile/General/EventsView/EventsView.jsx b/src/sections/Profile/General/EventsView/EventsView.jsx index 946b85d..43c0f81 100644 --- a/src/sections/Profile/General/EventsView/EventsView.jsx +++ b/src/sections/Profile/General/EventsView/EventsView.jsx @@ -6,7 +6,6 @@ import AuthContext from "../../../../context/AuthContext"; import { api } from "../../../../services"; import { ComponentLoading, MicroLoading } from "../../../../microInteraction"; -import { accessOrCreateEventByFormId } from "../../Admin/Form/CertificatesForm/tools/certificateTools"; import Link from "next/link"; const Events = () => { @@ -18,7 +17,9 @@ const Events = () => { const [certMap, setCertMap] = useState({}); const [loadingCerts, setLoadingCerts] = useState(true); // New state for certificate loading - const viewPath = "/profile/Events"; + // The public event page. There is no /profile/Events route, so the View + // button in this table used to 404. + const viewPath = "/Events"; const analyticsPath = "/profile/events/Analytics"; const analyticsAccessRoles = [ @@ -84,8 +85,12 @@ const Events = () => { }, [authCtx.user.email]); useEffect(() => { + // Not admin-gated. This is the participant's own certificate list, and the + // endpoint now authorises "your own email, or any email if you are an + // admin" — gating it here left every ordinary member with an empty list and + // a View button that led nowhere. const fetchCertificates = async () => { - if (authCtx.user?.access !== "ADMIN") return; + if (!authCtx.user?.email) return; try { const response = await api.post( @@ -105,16 +110,22 @@ const Events = () => { } }; - if (authCtx.user?.access === "ADMIN") { - fetchCertificates(); - } - }, [authCtx.user?.email, authCtx.user?.access, authCtx.token]); + fetchCertificates(); + }, [authCtx.user?.email, authCtx.token]); - const getCertificateForEvent = async (eventId) => { - const eid = await accessOrCreateEventByFormId(eventId, authCtx.token); - // console.log(eid, certificates[0].cert.eventId); - const found = certificates.find((item) => item.cert.eventId == eid.id); - // console.log(found); + /** + * Certificates are issued against an `Event`, while this table lists forms, + * so the two are matched on the `formId` the Event carries. + * + * This used to call `accessOrCreateEventByFormId`, which posts to + * /api/certificate/getEventByFormId and falls back to + * /api/certificate/createOrganisationEvent. Neither route was ever ported, so + * every lookup 404'd, threw on `eid.id`, and left the map empty — a second, + * independent reason the View button led nowhere. The joined event now comes + * back with the certificate itself, so no extra request is needed. + */ + const getCertificateForEvent = (formId) => { + const found = certificates.find((item) => item.event?.formId === formId); return found ? found.cert : null; }; @@ -131,29 +142,16 @@ const Events = () => { .replace(/\//g, "-"); }; + // Pure lookups against data already in hand, so this is a plain synchronous + // pass rather than one request per event. useEffect(() => { - const fetchAllCerts = async () => { - setLoadingCerts(true); // Start loading - const map = {}; - if (events.length > 0) { - for (const event of events) { - const cert = await getCertificateForEvent(event.id, authCtx.token); - if (cert) { - const link = `/verify/certificate?id=${cert.id}`; - map[event.id] = link; - } - } - } - setCertMap(map); - setLoadingCerts(false); // End loading - }; - - if (events.length > 0 && certificates.length > 0) { - fetchAllCerts(); - } else if (events.length > 0 && !isLoading) { - // If events are loaded but no certificates found - setLoadingCerts(false); + const map = {}; + for (const event of events) { + const cert = getCertificateForEvent(event.id); + if (cert) map[event.id] = `/verify/certificate?id=${cert.id}`; } + setCertMap(map); + setLoadingCerts(false); }, [events, certificates]); return ( diff --git a/src/utils/cloudinary.js b/src/utils/cloudinary.js new file mode 100644 index 0000000..f60644b --- /dev/null +++ b/src/utils/cloudinary.js @@ -0,0 +1,71 @@ +/** + * Rewrite a Cloudinary delivery URL to ask for a sensibly-sized image. + * + * Most of the media on this site was uploaded straight from a phone or a DSLR + * and is delivered untouched: measured on the home page, 22 MB of images across + * 22 requests, roughly 520 MB once decoded, including a single 8.2 MB + * 6000x4000 JPEG displayed in a 208px box. That is the mobile lag — a phone + * spends its time downloading and decoding pixels it then throws away. + * + * `IMAGE_SIZES` in lib/config/images.ts caps *uploads*, so it does nothing for + * the images already stored. This caps *delivery* instead, which works on + * everything, costs one string edit, and is cached by Cloudinary after the + * first request. + * + * f_auto — WebP/AVIF where the browser supports it + * q_auto — per-image quality, typically 30-60% smaller than the original + * c_limit — scale down to fit the width, never up, aspect ratio preserved + * dpr_auto — one extra pull on retina screens, handled by Cloudinary + * + * Anything that is not a Cloudinary URL, or already carries a transformation, + * is returned untouched. + * + * @param {string} url The image URL. + * @param {number} width Widest the image will ever be drawn, in CSS pixels. + */ +/** + * Widths Next's image optimiser will serve. `/_next/image` rejects any `w` that + * is not in `deviceSizes` + `imageSizes`, so requests are snapped to one of + * these rather than passed through raw. + */ +const NEXT_WIDTHS = [ + 16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, + 3840, +]; + +const snap = (width) => + NEXT_WIDTHS.find((w) => w >= width) ?? NEXT_WIDTHS[NEXT_WIDTHS.length - 1]; + +export function cdn(url, width = 800) { + if (typeof url !== "string" || !url) return url; + + const w = Math.max(1, Math.round(width)); + + if (url.includes("res.cloudinary.com")) { + const marker = "/upload/"; + const at = url.indexOf(marker); + if (at === -1) return url; + + const after = url.slice(at + marker.length); + // Already transformed — a transformation segment is a comma-joined list of + // `x_y` pairs before the next slash. Leave it alone rather than stacking. + if (/^[a-z]{1,3}_[^/]*\//.test(after) && !/^v\d+\//.test(after)) return url; + + return `${url.slice(0, at + marker.length)}f_auto,q_auto,c_limit,w_${w},dpr_auto/${after}`; + } + + // Other remote hosts — chiefly the Webflow CDN the carousel photos still live + // on, where a single slide is a 4160x3120 original shown in a 277px box. Next + // resizes and re-encodes those to AVIF/WebP. Only hosts listed under + // `images.remotePatterns` in next.config.ts are eligible; anything else would + // 400, so unknown hosts are left alone. + // Root-relative paths are files in /public and are optimisable too — the hero + // backdrop is a 1536x1024 PNG drawn in a 156px column, twice. + if (/^https?:\/\//.test(url) || (url.startsWith("/") && !url.startsWith("//"))) { + return `/_next/image?url=${encodeURIComponent(url)}&w=${snap(w)}&q=75`; + } + + return url; +} + +export default cdn; diff --git a/src/utils/prerequisite.js b/src/utils/prerequisite.js new file mode 100644 index 0000000..01de541 --- /dev/null +++ b/src/utils/prerequisite.js @@ -0,0 +1,42 @@ +/** + * The "locked" rule for events that require another event first. + * + * A form's `info.relatedEvent` holds the id of a prerequisite event. Until the + * visitor has registered for that event, this one stays locked — the behaviour + * the Express backend had, where the card reads "Locked" and registration is + * refused. + * + * This is deliberately the same test the server applies in + * app/api/form/register/route.ts, so the button and the API cannot disagree. + * + * It replaces a page-level `isRegisteredInRelatedEvents` boolean that both + * /Events and the event detail page computed like this: + * + * const relatedEventIds = ongoingEvents.map((e) => e.info.relatedEvent)… + * registeredInRelated = relatedEventIds.some((id) => registered.includes(id)); + * + * That asked "is the visitor registered for *any* event that is a prerequisite + * of *any* other event", then applied the answer to every card on the page. So + * registering for one prerequisite silently unlocked every gated event on the + * site, whatever its own prerequisite was. It also only ever set the flag true + * and never back to false, so the state survived a change of events. + */ + +const OBJECT_ID = /^[a-f\d]{24}$/i; + +/** True when the event names a real prerequisite event. */ +export function hasPrerequisite(info) { + const id = info?.relatedEvent; + return Boolean(id && id !== "null" && OBJECT_ID.test(id)); +} + +/** + * True when this specific event is open to this specific visitor. + * + * @param {object} info The event's `info` object. + * @param {string[]} regForm Ids of the forms the visitor has registered for. + */ +export function isPrerequisiteMet(info, regForm) { + if (!hasPrerequisite(info)) return true; + return Array.isArray(regForm) && regForm.includes(info.relatedEvent); +} diff --git a/src/views/AttendancePage/AttendancePage.jsx b/src/views/AttendancePage/AttendancePage.jsx index 555406a..cb62fea 100644 --- a/src/views/AttendancePage/AttendancePage.jsx +++ b/src/views/AttendancePage/AttendancePage.jsx @@ -424,7 +424,6 @@ const AttendancePage = () => { { { - return ( -
- ) -} + return ; +}; export default LoginMain \ No newline at end of file diff --git a/src/views/Event/Event.jsx b/src/views/Event/Event.jsx index c82da37..eddfb30 100644 --- a/src/views/Event/Event.jsx +++ b/src/views/Event/Event.jsx @@ -26,10 +26,10 @@ const Event = () => { const [pastEvents, setPastEvents] = useState([]); const [ongoingEvents, setOngoingEvents] = useState([]); const recoveryCtx = useContext(RecoveryContext); - const [isRegisteredInRelatedEvents, setIsRegisteredInRelatedEvents] = - useState(false); const [eventName, setEventName] = useState(""); - const [parentEventCount, setParentEventCount] = useState([]); + // A count, not a list — it was initialised to [], so `=== 0` was false until + // the effect below first ran. + const [parentEventCount, setParentEventCount] = useState(0); useEffect(() => { if ( @@ -140,22 +140,6 @@ const Event = () => { ); setParentEventCount(parentEvents.length); - - const relatedEventIds = ongoingEvents - .map((event) => event.info.relatedEvent) - .filter((id) => id !== null && id !== undefined && id !== "null") - .filter((id, index, self) => self.indexOf(id) === index); - - let registeredInRelated = false; - if (registeredEventIds.length > 0 && relatedEventIds.length > 0) { - registeredInRelated = relatedEventIds.some((relatedEventId) => - registeredEventIds.includes(relatedEventId) - ); - } - - if (registeredInRelated) { - setIsRegisteredInRelatedEvents(true); - } }, [ongoingEvents, pastEvents, authCtx.user.regForm]); const teamCodeAndName = { @@ -196,8 +180,9 @@ const Event = () => { return `In ${months} month${months > 1 ? "s" : ""}`; })(); + // `parentEventCount === 0` already says "has not registered for any event + // that gates others", which is exactly what this notice is for. const showPrerequisiteNotice = - !isRegisteredInRelatedEvents && parentEventCount === 0 && authCtx.isLoggedIn && authCtx.user.access === "USER" && @@ -289,9 +274,7 @@ const Event = () => { onOpen={() => {}} type="ongoing" variant="featured" - modalpath="/Events/" isLoading={false} - isRegisteredInRelatedEvents={isRegisteredInRelatedEvents} eventName={eventName} /> @@ -324,9 +307,7 @@ const Event = () => { data={event} onOpen={() => {}} type="ongoing" - modalpath="/Events/" isLoading={false} - isRegisteredInRelatedEvents={isRegisteredInRelatedEvents} eventName={eventName} /> ))} @@ -353,7 +334,6 @@ const Event = () => { data={event} onOpen={() => {}} type="past" - modalpath="/Events/pastEvents/" isLoading={false} /> ))} diff --git a/src/views/Event/EventDetail.jsx b/src/views/Event/EventDetail.jsx index 799be64..9dde2dc 100644 --- a/src/views/Event/EventDetail.jsx +++ b/src/views/Event/EventDetail.jsx @@ -15,6 +15,7 @@ import AuthContext from "../../context/AuthContext"; import { api } from "../../services"; import { Alert, MicroLoading, ComponentLoading } from "../../microInteraction"; import Share from "../../features/Modals/Event/ShareModal/ShareModal"; +import { isPrerequisiteMet } from "../../utils/prerequisite"; import style from "./styles/EventDetail.module.scss"; /** @@ -42,8 +43,6 @@ const EventDetail = () => { const [remainingTime, setRemainingTime] = useState(""); const [btnTxt, setBtnTxt] = useState("Register Now"); const [ongoingEvents, setOngoingEvents] = useState([]); - const [isRegisteredInRelatedEvents, setIsRegisteredInRelatedEvents] = - useState(false); const [isShareOpen, setShareOpen] = useState(false); const [shareUrl, setShareUrl] = useState(""); @@ -153,51 +152,31 @@ const EventDetail = () => { } }, [info.isRegistrationClosed, info.isEventPast, remainingTime]); - useEffect(() => { - const registeredEventIds = authCtx.user?.regForm || []; - const relatedEventIds = ongoingEvents - .map((event) => event.info.relatedEvent) - .filter((id) => id !== null && id !== undefined && id !== "null") - .filter((id, index, self) => self.indexOf(id) === index); - - if (registeredEventIds.length > 0 && relatedEventIds.length > 0) { - const matched = relatedEventIds.some((id) => - registeredEventIds.includes(id) - ); - if (matched) setIsRegisteredInRelatedEvents(true); - } - }, [ongoingEvents, authCtx.user?.regForm]); - useEffect(() => { if (!authCtx.isLoggedIn || !authCtx.user?.regForm || !data) return; - if (info.isRegistrationClosed) { - setBtnTxt("Closed"); - } - - const isRegistered = authCtx.user.regForm.includes(data.id); - const hasNoPrerequisite = data?.info?.relatedEvent === "null"; - const openState = () => { if (remainingTime) return remainingTime; if (data?.info?.isRegistrationClosed) return "Closed"; return "Register Now"; }; - if (isRegisteredInRelatedEvents) { - if (hasNoPrerequisite) { - if (isRegistered) setBtnTxt("Already Registered"); - } else { - setBtnTxt(isRegistered ? "Already Registered" : openState()); - } + if (authCtx.user.regForm.includes(data.id)) { + setBtnTxt("Already Registered"); return; } - if (hasNoPrerequisite) { - setBtnTxt(isRegistered ? "Already Registered" : openState()); - } else if (authCtx.user.access === "USER") { + // This event's own prerequisite, not "any prerequisite anywhere on the + // page" — see src/utils/prerequisite.js. Admins stay unlocked. + if ( + !isPrerequisiteMet(data?.info, authCtx.user.regForm) && + authCtx.user.access === "USER" + ) { setBtnTxt(data?.info?.isRegistrationClosed ? "Closed" : "Locked"); + return; } + + setBtnTxt(openState()); }, [ authCtx.isLoggedIn, authCtx.user?.regForm, @@ -205,7 +184,6 @@ const EventDetail = () => { data, info.isRegistrationClosed, info.isEventPast, - isRegisteredInRelatedEvents, remainingTime, ]); diff --git a/src/views/Event/PastEvent.jsx b/src/views/Event/PastEvent.jsx index dde53f0..ed942ee 100644 --- a/src/views/Event/PastEvent.jsx +++ b/src/views/Event/PastEvent.jsx @@ -112,7 +112,6 @@ const PastEvent = () => { data={event} type="past" customStyles={customStyles} - modalpath="/pastEvents/" aosDisable={false} />