diff --git a/public/events/clueminati.png b/public/events/clueminati.png new file mode 100644 index 00000000..12d67624 Binary files /dev/null and b/public/events/clueminati.png differ diff --git a/public/events/cookoff.jpg b/public/events/cookoff.jpg new file mode 100644 index 00000000..c0c21fec Binary files /dev/null and b/public/events/cookoff.jpg differ diff --git a/src/components/CatalogueContent.tsx b/src/components/CatalogueContent.tsx index 2dcde869..42a42cbe 100644 --- a/src/components/CatalogueContent.tsx +++ b/src/components/CatalogueContent.tsx @@ -19,6 +19,8 @@ import { FilterProvider, useFilters } from "@/context/filterContext"; import EmptyState from "./ui/EmptyState"; import SidebarButton from "./SidebarButton"; import SortComponent from "./ui/sorting"; +import Announcement from "./ui/announcement/Announcement"; +import { getSubjectEvent, EVENTS, type EventData } from "@/config/events"; const CatalogueContentInner = ({ subject }: { subject: string | null }) => { const [isMounted, setIsMounted] = useState(false); @@ -235,6 +237,95 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => { setAppliedFilters, ]); + const [activeEvent, setActiveEvent] = useState(null); + const [isGridEventsDismissed, setIsGridEventsDismissed] = useState(false); + + useEffect(() => { + try { + setIsGridEventsDismissed( + window.sessionStorage.getItem("announcement:global-grid-card:dismissed") === "true", + ); + } catch { + setIsGridEventsDismissed(false); + } + }, []); + + const handleDismissGridEvents = () => { + setIsGridEventsDismissed(true); + try { + window.sessionStorage.setItem( + "announcement:global-grid-card:dismissed", + "true", + ); + } catch {} + }; + + useEffect(() => { + setActiveEvent(getSubjectEvent(subject)); + }, [subject]); + + const renderGridItems = (paperList: IPaper[]) => { + if (paperList.length === 0) return null; + const currentEvent = activeEvent ?? EVENTS[0]!; + + const items: React.ReactNode[] = []; + paperList.forEach((paper, index) => { + if (index === 2 && !isGridEventsDismissed) { + items.push( + , + ); + } + items.push( + p._id === paper._id)} + />, + ); + }); + + if (paperList.length > 0 && paperList.length < 2 && !isGridEventsDismissed) { + items.push( + , + ); + } + + return items; + }; + // Render loading state until mounted to avoid hydration mismatch if (!isMounted) { return ; @@ -338,30 +429,14 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => { > {appliedFilters ? ( paginatedPapers.length > 0 ? ( - paginatedPapers.map((paper: IPaper) => ( - p._id === paper._id, - )} - /> - )) + renderGridItems(paginatedPapers) ) : (
) ) : ( - paginatedPapers.map((paper: IPaper) => ( - p._id === paper._id)} - /> - )) + renderGridItems(paginatedPapers) )} {totalPages > 1 && ( diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 27f56e69..4be2b93b 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -22,17 +22,48 @@ import { import { useCourses } from "@/context/courseContext"; import PinnedModal from "./ui/PinnedModal"; import RequestModal from "./ui/RequestModal"; -//import CookoffBanner from "./CookoffBanner"; +import Announcement from "./ui/announcement/Announcement"; +import { EVENTS } from "@/config/events"; function Navbar() { const pathname: string = usePathname() ?? "/"; const [dropdownOpen, setDropdownOpen] = useState(false); + const [currentBannerIndex, setCurrentBannerIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const [isTopBannerDismissed, setIsTopBannerDismissed] = useState(false); const { courses } = useCourses(); useEffect(() => { - if (pathname !== "/catalogue") return; - }, [pathname]); + try { + setIsTopBannerDismissed( + window.sessionStorage.getItem("announcement:global-top-banner:dismissed") === "true", + ); + } catch { + setIsTopBannerDismissed(false); + } + }, []); + + useEffect(() => { + if (isPaused || EVENTS.length <= 1) return; + + const interval = setInterval(() => { + setCurrentBannerIndex((prevIndex) => (prevIndex + 1) % EVENTS.length); + }, 5000); + + return () => clearInterval(interval); + }, [isPaused]); + + const handleDismissTopBanner = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setIsTopBannerDismissed(true); + try { + window.sessionStorage.setItem( + "announcement:global-top-banner:dismissed", + "true", + ); + } catch {} + }; const renderHomePageButtons = () => ( <> @@ -59,16 +90,39 @@ function Navbar() { return (
- {/**/} - {/* */} + {EVENTS.length > 0 && !isTopBannerDismissed && ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + > +
+ {EVENTS.map((event) => ( +
+ +
+ ))} +
+
+ )}
{} diff --git a/src/components/ui/announcement/Announcement.tsx b/src/components/ui/announcement/Announcement.tsx index 9afd4f11..4f3d4328 100644 --- a/src/components/ui/announcement/Announcement.tsx +++ b/src/components/ui/announcement/Announcement.tsx @@ -3,38 +3,29 @@ import * as React from "react"; import Image from "next/image"; import Link from "next/link"; -import { X, Megaphone, ArrowUpRight, type LucideIcon } from "lucide-react"; +import { X, ArrowUpRight, ExternalLink } from "lucide-react"; import { cn } from "@/lib/utils"; +import { EventLogo } from "./EventLogo"; export type AnnouncementAccent = "purple" | "amber" | "green" | "rose"; export type AnnouncementVariant = "banner" | "card"; export interface AnnouncementProps { - /** - * Stable, unique id for this announcement (e.g. "cookoff-2026"). - * Used as the localStorage key so a dismissal is remembered per-campaign, - * not globally — dismissing one promo doesn't hide future ones. - */ id: string; title: string; message: string; variant?: AnnouncementVariant; - /** Call-to-action label, e.g. "Register now". Omit to render without a CTA. */ ctaLabel?: string; - /** Where the CTA (and, for banners, the whole strip) links to. External links open in a new tab automatically. */ href?: string; - /** Card-variant only: image shown above the copy (16:9). Ignored for banners. */ + secondaryCtaLabel?: string; + secondaryHref?: string; imageUrl?: string; imageAlt?: string; - /** Custom lucide icon; defaults to a megaphone. */ - icon?: LucideIcon; - /** Color preset. Pick based on urgency/category, not vibes: purple = general promo, amber = time-sensitive, green = opportunity/open, rose = urgent/closing soon. */ + logoType?: "clueminati" | "cookoff" | "default"; + badge?: string; accent?: AnnouncementAccent; - /** Whether the person can dismiss it. Defaults to true. */ dismissible?: boolean; - /** ISO date string. Past this date the announcement stops rendering, dismissed or not — no stale promos. */ expiresAt?: string; - /** Shows a small "Sponsored" tag for transparency. Defaults to true — this is an ad, say so. */ sponsored?: boolean; onDismiss?: () => void; className?: string; @@ -50,48 +41,58 @@ const accentStyles: Record< chipIcon: string; accentText: string; cta: string; + secondaryCta: string; tagText: string; + badgeBg: string; } > = { purple: { - cardBorder: "border-[#734DFF] dark:border-[#36266D]", - cardSurface: "bg-white dark:bg-[#171720]", - bannerSurface: "bg-[#EFEAFF] dark:bg-[#171720]", - chip: "bg-[#EFEAFF] dark:bg-[#262635]", - chipIcon: "text-[#562EE7] dark:text-[#A79DFF]", - accentText: "text-[#562EE7] dark:text-[#A79DFF]", - cta: "bg-[#734DFF] text-white hover:bg-[#5F3FE0]", - tagText: "text-[#734DFF] dark:text-[#A79DFF]", + cardBorder: "border-[#734DFF] dark:border-[#562EE7]", + cardSurface: "bg-white dark:bg-[#120B24] hover:bg-[#EFEAFF] hover:dark:bg-[#1D1438]", + bannerSurface: "bg-[#EFEAFF] dark:bg-[#1A1133]", + chip: "bg-[#EFEAFF] dark:bg-[#231845]", + chipIcon: "text-[#562EE7] dark:text-[#C4B5FD]", + accentText: "text-[#562EE7] dark:text-[#A78BFA]", + cta: "bg-[#734DFF] text-white hover:bg-[#5F3FE0] dark:bg-[#6D28D9] dark:hover:bg-[#5B21B6]", + secondaryCta: "border border-[#734DFF] text-[#562EE7] hover:bg-[#EFEAFF] dark:border-[#7C3AED] dark:text-[#C4B5FD] dark:hover:bg-[#2E1A47]", + tagText: "text-[#734DFF] dark:text-[#C4B5FD]", + badgeBg: "bg-[#EFEAFF] text-[#562EE7] dark:bg-[#2E1A47] dark:text-[#C4B5FD]", }, amber: { - cardBorder: "border-[#d97706] dark:border-[#7c4a08]", - cardSurface: "bg-white dark:bg-[#171720]", - bannerSurface: "bg-[#fef3c7] dark:bg-[#3a2a05]", - chip: "bg-[#fde8b8] dark:bg-[#4a3608]", - chipIcon: "text-[#92600b] dark:text-[#f5c451]", - accentText: "text-[#92600b] dark:text-[#f5c451]", - cta: "bg-[#d97706] text-white hover:bg-[#b8630a]", - tagText: "text-[#92600b] dark:text-[#f5c451]", + cardBorder: "border-[#D97706] dark:border-[#B45309]", + cardSurface: "bg-white dark:bg-[#1C1305] hover:bg-[#FEF3C7] hover:dark:bg-[#2B1B07]", + bannerSurface: "bg-[#FEF3C7] dark:bg-[#2A1C07]", + chip: "bg-[#FDE8B8] dark:bg-[#3D290A]", + chipIcon: "text-[#92600B] dark:text-[#FCD34D]", + accentText: "text-[#92600B] dark:text-[#FBBF24]", + cta: "bg-[#D97706] text-white hover:bg-[#B8630A] dark:bg-[#D97706] dark:hover:bg-[#B45309]", + secondaryCta: "border border-[#D97706] text-[#92600B] hover:bg-[#FEF3C7] dark:border-[#F59E0B] dark:text-[#FCD34D] dark:hover:bg-[#3D290A]", + tagText: "text-[#92600B] dark:text-[#FCD34D]", + badgeBg: "bg-[#FEF3C7] text-[#92600B] dark:bg-[#3D290A] dark:text-[#FCD34D]", }, green: { - cardBorder: "border-[#16a34a] dark:border-[#0f6b32]", - cardSurface: "bg-white dark:bg-[#171720]", - bannerSurface: "bg-[#dcfce7] dark:bg-[#052e13]", - chip: "bg-[#c7f7d4] dark:bg-[#0b3d1c]", - chipIcon: "text-[#15803d] dark:text-[#6ee7a0]", - accentText: "text-[#15803d] dark:text-[#6ee7a0]", - cta: "bg-[#16a34a] text-white hover:bg-[#128a3e]", - tagText: "text-[#15803d] dark:text-[#6ee7a0]", + cardBorder: "border-[#16A34A] dark:border-[#15803D]", + cardSurface: "bg-white dark:bg-[#071C0F] hover:bg-[#DCFCE7] hover:dark:bg-[#0E2918]", + bannerSurface: "bg-[#DCFCE7] dark:bg-[#0B331A]", + chip: "bg-[#C7F7D4] dark:bg-[#124D28]", + chipIcon: "text-[#15803D] dark:text-[#86EFAC]", + accentText: "text-[#15803D] dark:text-[#4ADE80]", + cta: "bg-[#16A34A] text-white hover:bg-[#128A3E]", + secondaryCta: "border border-[#16A34A] text-[#15803D] hover:bg-[#DCFCE7] dark:border-[#22C55E] dark:text-[#86EFAC] dark:hover:bg-[#124D28]", + tagText: "text-[#15803D] dark:text-[#86EFAC]", + badgeBg: "bg-[#DCFCE7] text-[#15803D] dark:bg-[#124D28] dark:text-[#86EFAC]", }, rose: { - cardBorder: "border-[#e11d48] dark:border-[#8a0f2c]", - cardSurface: "bg-white dark:bg-[#171720]", - bannerSurface: "bg-[#ffe4e6] dark:bg-[#3f0d16]", - chip: "bg-[#ffd2d6] dark:bg-[#521022]", - chipIcon: "text-[#be123c] dark:text-[#fda4b0]", - accentText: "text-[#be123c] dark:text-[#fda4b0]", - cta: "bg-[#e11d48] text-white hover:bg-[#c11640]", - tagText: "text-[#be123c] dark:text-[#fda4b0]", + cardBorder: "border-[#E11D48] dark:border-[#BE123C]", + cardSurface: "bg-white dark:bg-[#1F070C] hover:bg-[#FFE4E6] hover:dark:bg-[#2F0B13]", + bannerSurface: "bg-[#FFE4E6] dark:bg-[#380D17]", + chip: "bg-[#FFD2D6] dark:bg-[#521323]", + chipIcon: "text-[#BE123C] dark:text-[#FDA4AF]", + accentText: "text-[#BE123C] dark:text-[#FB7185]", + cta: "bg-[#E11D48] text-white hover:bg-[#C11640]", + secondaryCta: "border border-[#E11D48] text-[#BE123C] hover:bg-[#FFE4E6] dark:border-[#F43F5E] dark:text-[#FDA4AF] dark:hover:bg-[#521323]", + tagText: "text-[#BE123C] dark:text-[#FDA4AF]", + badgeBg: "bg-[#FFE4E6] text-[#BE123C] dark:bg-[#521323] dark:text-[#FDA4AF]", }, }; @@ -130,19 +131,6 @@ function SmartLink({ ); } -/** - * Reusable promo/announcement unit for advertising events, deadlines, or - * sponsors on the papers site. Two variants: - * - * - "banner": a thin dismissible strip, meant for the top of a page. - * - "card": a promo card sized to match `Card.tsx`, meant to sit inside - * the same paper grid (`grid-cols-1 md:grid-cols-2 lg:grid-cols-4`) as - * a native-feeling in-feed ad. - * - * Dismissal is remembered per `id` in localStorage, and an optional - * `expiresAt` retires the announcement automatically so stale promos never - * linger after an event has passed. - */ export default function Announcement({ id, title, @@ -150,9 +138,12 @@ export default function Announcement({ variant = "banner", ctaLabel, href, + secondaryCtaLabel, + secondaryHref, imageUrl, imageAlt, - icon: Icon = Megaphone, + logoType = "default", + badge, accent = "purple", dismissible = true, expiresAt, @@ -176,18 +167,20 @@ export default function Announcement({ setMounted(true); if (dismissible) { try { - setDismissed(window.localStorage.getItem(storageKey) === "true"); + setDismissed(window.sessionStorage.getItem(storageKey) === "true"); } catch { setDismissed(false); } } }, [storageKey, dismissible]); - const handleDismiss = () => { + const handleDismiss = (e: React.MouseEvent) => { + e.stopPropagation(); setDismissed(true); try { - window.localStorage.setItem(storageKey, "true"); + window.sessionStorage.setItem(storageKey, "true"); } catch { + // ignore quota / security error } onDismiss?.(); }; @@ -196,43 +189,64 @@ export default function Announcement({ const styles = accentStyles[accent]; + const handleBannerClick = (e: React.MouseEvent) => { + if (!href) return; + const target = e.target as HTMLElement; + if (target.closest("button, a, input, svg")) return; + + if (isExternal(href)) { + window.open(href, "_blank", "noopener,noreferrer"); + } else { + window.location.href = href; + } + }; + if (variant === "banner") { return (
-
-
- -
+
+ {imageUrl ? ( + {imageAlt + ) : ( + + )} -
- +
+ {badge && ( + + {badge} + + )} + {title} - + {message}
-
- {sponsored && ( - - Sponsored - - )} - +
{ctaLabel && href && ( @@ -241,11 +255,24 @@ export default function Announcement({ )} + {secondaryCtaLabel && secondaryHref && ( + + )} + {dismissible && ( @@ -256,13 +283,27 @@ export default function Announcement({ ); } - // Card variant — sized to sit inside the same grid as paper Cards. + const handleCardClick = (e: React.MouseEvent) => { + if (!href) return; + const target = e.target as HTMLElement; + if (target.closest("button, a, input, svg")) return; + + if (isExternal(href)) { + window.open(href, "_blank", "noopener,noreferrer"); + } else { + window.location.href = href; + } + }; + + // Card variant — sized to sit inside the same grid as paper Cards in Catalogue. return (
- + )} - {imageUrl ? ( - {imageAlt - ) : ( -
- -
- )} + {/* Header Banner Graphic / Image */} +
+ {imageUrl ? ( + {imageAlt + ) : ( +
+ + {badge && ( + + {badge} + + )} +
+ )} +
+ +
-
-
- {sponsored && ( - - Sponsored + {/* Details & Copy */} +
+
+
+ + CodeChef-VIT Event - )} -
{title}
-

{message}

+ + Gravitas '26 + +
+ +

+ {title} +

+

+ {message} +

- {ctaLabel && href && ( - - {ctaLabel} - - - )} + {/* Action Buttons */} +
+ {ctaLabel && href && ( + + {ctaLabel} + + + )} + + {secondaryCtaLabel && secondaryHref && ( + + {secondaryCtaLabel} + + + )} +
); diff --git a/src/components/ui/announcement/EventLogo.tsx b/src/components/ui/announcement/EventLogo.tsx new file mode 100644 index 00000000..1954aa8d --- /dev/null +++ b/src/components/ui/announcement/EventLogo.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; + +interface EventLogoProps { + type?: "clueminati" | "cookoff" | "default"; + className?: string; + size?: "sm" | "md" | "lg"; +} + +export function EventLogo({ type = "default", className, size = "md" }: EventLogoProps) { + const sizeClasses = { + sm: "h-8 w-8", + md: "h-12 w-12", + lg: "h-20 w-20 md:h-24 md:w-24", + }; + + if (type === "clueminati") { + return ( +
+ + + + + + + + + + + + + {/* Cyber Eye / Magnifier Outer Ring */} + + + + {/* Magnifier Handle */} + + + {/* Inner Code Bracket / Mystery Lock Element */} + + + + + {/* Glowing Sparkles */} + + + +
+ ); + } + + if (type === "cookoff") { + return ( +
+ + + + + + + + + + + + + + {/* Tech Chef Hat Base */} + + {/* Hat Ribbon / Code Bar */} + + + + {/* Central Coding Flame */} + + + {/* Sparkles */} + + + +
+ ); + } + + // Default CodeChef-VIT Brand Logo Accent + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/src/config/events.ts b/src/config/events.ts new file mode 100644 index 00000000..4172d9e8 --- /dev/null +++ b/src/config/events.ts @@ -0,0 +1,53 @@ +export interface EventData { + id: string; + title: string; + tagline: string; + description: string; + badge: string; + accent: "purple" | "amber" | "green" | "rose"; + registrationUrl: string; + landingPageUrl: string; + imageUrl?: string; + dates?: string; + location?: string; + logoType: "clueminati" | "cookoff"; +} + +export const EVENTS: EventData[] = [ + { + id: "clueminati-2026", + title: "Clueminati 4.0", + tagline: "The Ultimate Tech Treasure Hunt & Gaming Mystery", + description: "Hunt for hidden clues, outsmart tricky puzzles, and level up your way to the top in CodeChef-VIT's gaming mystery event at Gravitas !", + badge: "CLUEMINATI 4.0", + accent: "purple", + registrationUrl: "https://gravitas.vit.ac.in/events/0615fb4d-78b0-4a7c-bc52-ba4cccee2ee6", + landingPageUrl: "https://gravitas.codechefvit.com/", + dates: "Gravitas '26", + location: "VIT Vellore", + logoType: "clueminati", + imageUrl: "/events/clueminati.png", + }, + { + id: "cookoff-2026", + title: "CookOff 11", + tagline: "Largest Competitive Coding Contest", + description: "Test your algorithmic problem solving skills against top coders. Win prizes, certificates, and glory at Gravitas !", + badge: "CookOff 11", + accent: "amber", + registrationUrl: "https://gravitas.vit.ac.in/events/0629102b-cc5f-4992-8921-2e55df1cb2f8", + landingPageUrl: "https://gravitas.codechefvit.com/", + dates: "Gravitas '26", + location: "VIT Vellore", + logoType: "cookoff", + imageUrl: "/events/cookoff.jpg", + }, +]; + +/** + * Always pick 100% randomly between available events on every load. + */ +export function getSubjectEvent(_subjectKey?: string | null): EventData { + const randomIndex = Math.floor(Math.random() * EVENTS.length); + return EVENTS[randomIndex]!; +}