Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/icons/Pause_Icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/icons/Start_Icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* Latest Updates (Gallery) desktop breakpoint: >=1167px */
--breakpoint-gallery-lg: 72.9375rem; /* 1167px */
}

/* @media (prefers-color-scheme: dark) {
Expand Down
61 changes: 37 additions & 24 deletions src/components/LandingPage/Gallery/UpdateCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from "react";
import Image from "next/image";
import externalLinkIcon from "../../../../assets/icons/external_link_icon.svg";

interface UpdateCardProps {
image: string;
Expand All @@ -16,49 +15,63 @@ const UpdateCard: React.FC<UpdateCardProps> = ({
link,
}) => {
return (
<div className="relative rounded-bl-[20px] rounded-tr-[20px] w-full h-[496px] sm:max-w-[367px]">
<div className="flex flex-col gap-px items-start overflow-hidden relative rounded-[inherit] w-full h-full">
<div className="aspect-[367/310] max-h-[310px] relative shrink-0 w-full sm:max-w-[367px]">
<div className="group relative rounded-bl-[20px] rounded-tr-[20px] w-full h-[278px] max-w-[215px] md:h-[278px] md:max-w-[215px] gallery-lg:h-[476px] gallery-lg:w-[367px] gallery-lg:max-w-[367px] gallery-lg:shrink-0">
<div className="flex flex-col gap-0 items-start overflow-hidden relative rounded-[inherit] w-full h-full">
<div className="h-[181px] md:h-[181px] gallery-lg:h-[310px] relative shrink-0 w-full rounded-tr-[20px] overflow-hidden gallery-lg:w-full">
<Image
src={image}
alt={title}
width={367}
height={310}
className="absolute inset-0 object-cover object-center pointer-events-none w-full h-full"
className="absolute inset-0 object-cover object-center pointer-events-none w-full h-full rounded-tr-[20px]"
/>
</div>
<div className="bg-[#323032] box-border flex flex-col flex-1 items-start overflow-hidden pb-[15px] pt-[11px] px-[18px] relative shrink-0 w-full">
<p className="[font-family:Nunito] font-bold leading-[20px] relative shrink-0 text-[16px] sm:text-[17px] md:text-[18px] text-white w-full mb-[5px] min-h-[40px]">
<div className="bg-[#044249] box-border flex flex-col flex-1 items-start overflow-hidden pb-[14px] pt-[8px] px-[14px] relative shrink-0 w-full md:pb-[14px] md:pt-[8px] md:px-[14px] gallery-lg:px-6 gallery-lg:pt-[14px] gallery-lg:pb-5">
<p className="font-poppins font-semibold leading-[16px] text-[14px] uppercase tracking-[-0.105px] md:tracking-[0.28px] text-[#72f9fb] w-full gallery-lg:mb-2 gallery-lg:min-h-0">
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#F6CB0E] cursor-pointer"
className="font-poppins text-[14px] font-semibold not-italic leading-[16px] text-[#72F9FB] hover:text-[#F6CB0E] cursor-pointer md:hover:text-[#72f9fb] gallery-lg:hover:text-[#72f9fb]"
>
{title}
</a>
</p>
<div className="[font-family:Nunito] font-normal leading-[24px] sm:leading-[26px] md:leading-[28px] text-[16px] sm:text-[17px] md:text-[18px] text-white">
<span>
<div className="hidden gallery-lg:block">
<p className="[font-family:Inter] font-normal text-white w-[326px] text-[16px] leading-[22px] mb-2">
{description.length > 100
? `${description.slice(0, 100)}...`
: description}
</span>
<span className="gap-[8px] items-center ml-1 inline-flex">
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="[text-decoration-skip-ink:none] [text-underline-position:from-font] decoration-solid [font-family:Nunito] font-medium leading-[28px] text-[#76e7dd] text-[14px] underline uppercase cursor-pointer whitespace-nowrap"
>
Read More
</a>
<div className="h-[11.915px] relative shrink-0 w-[11.295px]">
<Image src={externalLinkIcon} alt="external link" className="block max-w-none w-full h-full" width={12} height={12} />
</div>
</span>
</p>
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="font-inter font-normal not-italic text-[14px] leading-[22px] text-[#aff1ff] underline cursor-pointer hover:text-[#5ef2ff] inline-block mt-auto text-right w-full"
>
Read More &gt;
</a>
</div>
</div>
{/* Tablet/mobile only: hover overlay per Figma 2174-9260 — dark overlay, centered text, teal Read More */}
<div
className="absolute inset-0 rounded-[inherit] bg-black/70 flex flex-col justify-center p-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100 z-[1] gallery-lg:hidden"
aria-hidden
>
<p className="font-inter font-normal text-white text-[14px] leading-[20px] line-clamp-5 mb-3 text-left">
{description.length > 100
? `${description.slice(0, 100)}...`
: description}
</p>
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="font-inter font-normal text-[14px] leading-[22px] text-[#4BBFC6] underline decoration-[#4BBFC6] hover:text-[#72f9fb] hover:decoration-[#72f9fb] cursor-pointer w-fit text-left"
>
Read More &gt;
</a>
Comment on lines +56 to +73
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hover overlay is hidden via opacity-0 but still contains a focusable <a>; keyboard users can tab to an invisible link (and aria-hidden does not prevent focus). This is an accessibility issue. Consider disabling pointer events while hidden (e.g., pointer-events-none + enabling on group-hover/group-focus-within), and ensure the overlay can also be revealed on keyboard focus (not hover-only).

Copilot uses AI. Check for mistakes.
</div>
</div>
</div>
);
Expand Down
204 changes: 184 additions & 20 deletions src/components/LandingPage/Gallery/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use client";
import React from "react";
import React, { useRef, useState, useCallback, useEffect } from "react";
import Image from "next/image";
import UpdateCard from "./UpdateCard";
import startIcon from '../../../../assets/icons/Start_Icon.svg'
import pauseIcon from '../../../../assets/icons/Pause_Icon.svg'

const AUTO_ROTATE_INTERVAL_MS = 5000;

export interface GalleryUpdate {
image: string;
Expand All @@ -19,27 +24,183 @@ export interface GalleryConfig {
}

interface GalleryProps {
data: { gallery: GalleryConfig};
data: { gallery: GalleryConfig };
}

const CARD_WIDTH_MOBILE = 214;
const CARD_GAP_MOBILE = 28;
const SLIDE_STEP = CARD_WIDTH_MOBILE + CARD_GAP_MOBILE; // 242
const MOBILE_VIEWPORT_OFFSET = -SLIDE_STEP; // -242: show indices 1,2,3

/** Build initial mobile list [c, a, b, c, a, b] from updates [a, b, c] so visible indices 1,2,3 show a,b,c */
function buildMobileRotatingList(updates: GalleryUpdate[] | undefined): GalleryUpdate[] {
if (!updates || updates.length < 3) return [];
const [a, b, c] = updates;
return [c, a, b, c, a, b];
Comment on lines +35 to +39
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildMobileRotatingList only works for exactly 3 updates (it takes [a,b,c] and returns a hard-coded 6-item list). If config.updates ever contains <3 or >3 items (data-driven from home.json), the mobile carousel will render empty/incorrectly while still showing controls. Consider supporting arbitrary lengths (e.g., build a circular buffer from the full list) and/or conditionally hiding the mobile carousel + controls when there aren’t enough items to slide.

Suggested change
/** Build initial mobile list [c, a, b, c, a, b] from updates [a, b, c] so visible indices 1,2,3 show a,b,c */
function buildMobileRotatingList(updates: GalleryUpdate[] | undefined): GalleryUpdate[] {
if (!updates || updates.length < 3) return [];
const [a, b, c] = updates;
return [c, a, b, c, a, b];
/** Build initial mobile list for the mobile carousel.
* - For exactly 3 updates [a, b, c], return [c, a, b, c, a, b] so visible indices 1,2,3 show a,b,c.
* - For fewer than 3 updates, return [] (not enough items to slide).
* - For more than 3 updates, return the full list unchanged.
*/
function buildMobileRotatingList(updates: GalleryUpdate[] | undefined): GalleryUpdate[] {
if (!updates || updates.length < 3) {
return [];
}
if (updates.length === 3) {
const [a, b, c] = updates;
return [c, a, b, c, a, b];
}
// For more than 3 updates, use the entire list without truncation.
return updates;

Copilot uses AI. Check for mistakes.
}

const MOBILE_MEDIA_QUERY = "(max-width: 767px)";

const Gallery: React.FC<GalleryProps> = ({ data }) => {
const config = data?.gallery;

const [rotatingList, setRotatingList] = useState<GalleryUpdate[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [slideOffset, setSlideOffset] = useState(MOBILE_VIEWPORT_OFFSET);
const [slideTransitionEnabled, setSlideTransitionEnabled] = useState(true);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const slideDirectionRef = useRef<"next" | "prev" | null>(null);
const slideTrackRef = useRef<HTMLDivElement>(null);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slideTrackRef is declared but never read. With Next/TypeScript ESLint rules this is typically flagged as an unused variable and can fail CI; remove it (and the ref={...}) or use it (e.g., for imperative scrolling/measurement).

Suggested change
const slideTrackRef = useRef<HTMLDivElement>(null);

Copilot uses AI. Check for mistakes.

useEffect(() => {
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const update = () => setIsMobileViewport(mql.matches);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);

useEffect(() => {
if (config?.updates && config.updates.length >= 3) {
setRotatingList(buildMobileRotatingList(config.updates));
}
}, [config?.updates]);

const handleSlideTransitionEnd = useCallback(() => {
const dir = slideDirectionRef.current;
if (dir === "next") {
setRotatingList((prev) =>
prev.length === 6 ? [...prev.slice(1), prev[0]] : prev
);
} else if (dir === "prev") {
setRotatingList((prev) =>
prev.length === 6 ? [prev[5], ...prev.slice(0, 5)] : prev
);
}
slideDirectionRef.current = null;
setSlideTransitionEnabled(false);
setSlideOffset(MOBILE_VIEWPORT_OFFSET);
requestAnimationFrame(() => {
requestAnimationFrame(() => setSlideTransitionEnabled(true));
});
}, []);

const goToNext = useCallback(() => {
if (rotatingList.length !== 6 || slideDirectionRef.current) return;
slideDirectionRef.current = "next";
setSlideOffset(-2 * SLIDE_STEP); // show indices 2,3,4
}, [rotatingList.length]);

const goToPrev = useCallback(() => {
if (rotatingList.length !== 6 || slideDirectionRef.current) return;
slideDirectionRef.current = "prev";
setSlideOffset(0); // show indices 0,1,2
}, [rotatingList.length]);

useEffect(() => {
if (!isMobileViewport || rotatingList.length !== 6 || isPaused) return;
const id = setInterval(goToNext, AUTO_ROTATE_INTERVAL_MS);
return () => clearInterval(id);
}, [isMobileViewport, rotatingList.length, isPaused, goToNext]);

if (!config) return null;

return (
<section className="flex flex-col items-center px-3 sm:px-8 xl:px-[136px] pb-[10px] pt-[36px] max-w-[1444px] mx-auto mb-[40px]" aria-labelledby="latest-updates-heading">
<h2
id="latest-updates-heading"
className="text-[#345D85] text-[24px] sm:text-[28px] md:text-[32px] [font-family:Inter] font-semibold leading-[38px] w-full"
>
{config.title}
</h2>
<div className="w-full mt-[31px] sm:mt-8 md:mt-[31px]">
<div className="w-full">
<div className="flex flex-wrap sm:flex-nowrap justify-center md:justify-end gap-4 lg:gap-[32px]">
<section
className="flex flex-col items-center px-4 pt-[24px] pb-[45px] sm:pt-[36px] sm:pb-[10px] md:pt-[30px] md:pb-[10px] gallery-lg:px-0 max-w-[1440px] mx-auto mb-[40px]"
aria-labelledby="latest-updates-heading"
>
<div className="w-full gallery-lg:max-w-[1165px] gallery-lg:mx-auto">
<h2
id="latest-updates-heading"
className="text-[#345D85] text-[24px] sm:text-[28px] md:text-[32px] [font-family:Inter] font-semibold leading-[38px] w-full"
>
{config.title}
</h2>
<div className="w-full mt-6 sm:mt-8 md:mt-[24px] gallery-lg:mt-[31px]">
<div className="w-full flex flex-col">
{/* Mobile: 6-item sliding track; viewport shows 3 cards; smooth slide then reset for infinite */}
<div className="md:hidden w-full">
<div
className="overflow-hidden mx-auto"
style={{ width: 3 * CARD_WIDTH_MOBILE + 2 * CARD_GAP_MOBILE }}
>
<div
ref={slideTrackRef}
className="flex gap-[28px] py-1"
style={{
width: 6 * CARD_WIDTH_MOBILE + 5 * CARD_GAP_MOBILE,
transform: `translateX(${slideOffset}px)`,
transition: slideTransitionEnabled
? "transform 0.45s ease-in-out"
: "none",
}}
onTransitionEnd={handleSlideTransitionEnd}
role="region"
aria-label="Latest updates carousel"
>
{rotatingList.map((update: GalleryUpdate, idx: number) => (
<div
key={`${idx}-${update.title}-${update.link}`}
className="flex-shrink-0 w-[214px] rounded-[0px_28px_0px_28px]"
>
<UpdateCard
image={update.image}
title={update.title}
description={update.description}
link={update.link}
/>
</div>
))}
</div>
</div>
{/* Pagination: Left | Pause | Right — infinite, no disabled */}
<div className="flex gap-[18px] items-center justify-center shrink-0 w-full mt-[30px]">
<button
type="button"
onClick={goToPrev}
aria-label="Previous slide"
className="group flex items-center justify-center rounded-full border border-[#4BBFC6] bg-transparent w-[39.158px] h-[39.158px] text-[#6B7280] touch-manipulation cursor-pointer"
>
<div
className="mr-[3px] w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[11px] border-b-[#C9C9C9] group-hover:border-b-[#8D9096] border-t-0 border-t-transparent -rotate-90"
aria-hidden
/>
</button>
<button
type="button"
onClick={() => setIsPaused((p) => !p)}
aria-label={isPaused ? "Play carousel" : "Pause carousel"}
className="flex items-center justify-center rounded-full border border-[#4BBFC6] bg-transparent w-[39.158px] h-[39.158px] text-[#6B7280] touch-manipulation cursor-pointer shadow-[0px_2.49px_9.32px_0px_#00000073]"
>
<Image
src={isPaused ? startIcon : pauseIcon}
alt={isPaused ? "Play carousel" : "Pause carousel"}
width={isPaused ? 20 : 18}
height={isPaused ? 22 : 18}
className={isPaused ? "w-[20px] h-[22px] pl-[4px]" : "w-[18px] h-[18px]"}
/>
</button>
<button
type="button"
onClick={goToNext}
aria-label="Next slide"
className="group flex items-center justify-center rounded-full border border-[#4BBFC6] bg-transparent w-[39.158px] h-[39.158px] text-[#6B7280] touch-manipulation cursor-pointer"
>
<div
className="ml-[3px] w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[11px] border-t-[#C9C9C9] group-hover:border-t-[#8D9096] border-b-0 border-b-transparent -rotate-90"
aria-hidden
/>
</button>
</div>
</div>

{/* Tablet + Desktop: row of cards; desktop only title and cards left-aligned */}
<div className="hidden md:flex flex-wrap sm:flex-nowrap justify-center md:justify-center gallery-lg:justify-start gap-4 md:gap-[33px] gallery-lg:gap-[32px]">
{config.updates.map((update: GalleryUpdate, idx: number) => (
<div className="w-full sm:flex-1 sm:min-w-0 sm:max-w-[367px]" key={idx}>
<div
className="w-full sm:flex-1 sm:min-w-0 sm:max-w-[367px] md:w-[215px] md:min-w-0 md:max-w-[215px] md:shrink-0 gallery-lg:flex-1 gallery-lg:max-w-[367px]"
key={idx}
>
<div className="flex items-center rounded-[0px_28px_0px_28px]">
<div className="my-auto w-full">
<UpdateCard
Expand All @@ -53,9 +214,11 @@ const Gallery: React.FC<GalleryProps> = ({ data }) => {
</div>
))}
</div>
{/* Button below the cards */}
<div className="flex justify-end mt-[22px] w-full">
<a

{/* Button: centered on mobile; on tablet align right with rightmost card (711px); on desktop in 1165px container */}
<div className="w-full md:max-w-[711px] md:mx-auto gallery-lg:max-w-full gallery-lg:mx-0">
<div className="flex justify-center mt-[24px] w-full md:justify-end">
<a
href={config.newsletterButtonLink}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -68,13 +231,14 @@ const Gallery: React.FC<GalleryProps> = ({ data }) => {
textAlign: "center",
}}
>
<span className="bg-[#06324E] text-white text-center [font-family:Poppins] text-[12px] font-semibold leading-[16px] tracking-[0.24px] uppercase flex h-[41px] py-[12px] px-[30px] justify-center items-center relative pb-[14px]">
<span className="bg-[#06324E] text-white text-center [font-family:Poppins] text-[12px] font-semibold leading-[16px] tracking-[0.24px] uppercase flex h-[41px] py-[12px] px-[30px] justify-center items-center rounded-[5px]">
{config.newsletterButtonText}
</span>
</a>
{/* End button */}
</div>
</div>
</div>
</div>
</div>
</section>
);
Expand Down
Loading