diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index b2875c5b..e3ddd14c 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -17,6 +17,7 @@ import { HowItWorks } from "./_sections/how-it-works"; import { WalletsTokens } from "@/components/sections/wallets-token"; import { AnimatedBackground } from "@/components/ui/animated-background"; import Navbar from "./_components/navbar"; +import { LiveTicker } from "@/app/components/LiveTicker"; import KpiStrip from "./_sections/kpi-strip"; import { CTA } from "./_sections/cta"; import { useParallax } from "@/hooks/use-parallax"; @@ -34,6 +35,7 @@ export default function MarketingRoute() { + diff --git a/app/components/LiveTicker.tsx b/app/components/LiveTicker.tsx new file mode 100644 index 00000000..dbd579c0 --- /dev/null +++ b/app/components/LiveTicker.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +interface TickerEvent { + id: string; + text: string; + timestamp: string; +} + +const mockEvents: TickerEvent[] = [ + { id: "1", text: "User 0x4F... placed 500 XLM on 'ETH > $4K'", timestamp: "1m ago" }, + { id: "2", text: "New Market created: US Elections 2028", timestamp: "3m ago" }, + { id: "3", text: "Market 'Super Bowl LIX' resolved to 'Chiefs'", timestamp: "5m ago" }, + { id: "4", text: "User 0xA1... claimed 1,200 XLM reward", timestamp: "12m ago" }, + { id: "5", text: "Dispute opened on 'SpaceX Mars Mission'", timestamp: "15m ago" }, + { id: "6", text: "User 0x9B... predicted Yes on 'Stellar Protocol 22'", timestamp: "18m ago" }, +]; + +export function LiveTicker() { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mediaQuery.matches); + + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); + } else { + mediaQuery.addListener(handler); + return () => mediaQuery.removeListener(handler); + } + }, []); + + if (prefersReducedMotion) { + return ( +
+
+ + + + Live Activity: + {mockEvents[0].text} + ({mockEvents[0].timestamp}) +
+
+ ); + } + + return ( + + ); +} diff --git a/app/components/__tests__/LiveTicker.test.tsx b/app/components/__tests__/LiveTicker.test.tsx new file mode 100644 index 00000000..0900fe75 --- /dev/null +++ b/app/components/__tests__/LiveTicker.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { LiveTicker } from "../LiveTicker"; +import "@testing-library/jest-dom"; + +const setReducedMotion = (matches: boolean) => { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)" ? matches : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +}; + +describe("LiveTicker", () => { + beforeEach(() => { + setReducedMotion(false); + }); + + describe("Normal Motion", () => { + it("renders the continuous marquee container when reduced motion is disabled", () => { + render(); + + const tickerContainer = screen.getByTestId("live-ticker-marquee"); + expect(tickerContainer).toBeInTheDocument(); + expect(tickerContainer).toHaveAttribute("aria-hidden", "true"); + + const marqueeElements = document.querySelectorAll(".animate-marquee"); + expect(marqueeElements.length).toBeGreaterThanOrEqual(2); + }); + + it("displays the mock events", () => { + render(); + const events = screen.getAllByText(/User 0x4F\.\.\. placed 500 XLM/i); + expect(events.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("Reduced Motion", () => { + it("falls back to a static accessible region when reduced motion is enabled", () => { + setReducedMotion(true); + render(); + + expect(screen.queryByTestId("live-ticker-marquee")).not.toBeInTheDocument(); + + const region = screen.getByRole("region", { name: "Recent Market Activity" }); + expect(region).toBeInTheDocument(); + + const eventText = screen.getByText(/User 0x4F\.\.\. placed 500 XLM/i); + expect(eventText).toBeInTheDocument(); + + const staticLabel = screen.getByText(/Live Activity:/i); + expect(staticLabel).toBeInTheDocument(); + }); + }); +}); diff --git a/components/disputes/shared/CountdownTimer.tsx b/components/disputes/shared/CountdownTimer.tsx index 0b47aac6..d4f94221 100644 --- a/components/disputes/shared/CountdownTimer.tsx +++ b/components/disputes/shared/CountdownTimer.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; interface CountdownTimerProps { deadline: Date; label?: string; + } interface TimeLeft { @@ -13,6 +14,7 @@ interface TimeLeft { hours: number; minutes: number; seconds: number; + } function computeTimeLeft(deadline: Date): TimeLeft | null { @@ -55,6 +57,7 @@ function buildAnnouncement(t: TimeLeft): string { if (parts.length === 0) return 'Less than one second remaining'; return `${parts.join(', ')} remaining`; + } /** @@ -119,6 +122,7 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) { const [expired, setExpired] = useState(() => { if (!isValidDate(deadline)) return false; return deadline.getTime() <= Date.now(); + }); // The string the aria-live region currently shows. Updates only at coarse @@ -210,9 +214,12 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) { > {visibleLabel} - - {announcement} - + {/* Avoid duplicate text and unnecessary live region updates when reduced motion is preferred */} + {!prefersReducedMotion && ( + + {announcement} + + )} ); } diff --git a/components/disputes/shared/__tests__/CountdownTimer.test.tsx b/components/disputes/shared/__tests__/CountdownTimer.test.tsx index be349ca8..ffe8ff27 100644 --- a/components/disputes/shared/__tests__/CountdownTimer.test.tsx +++ b/components/disputes/shared/__tests__/CountdownTimer.test.tsx @@ -200,6 +200,7 @@ describe('CountdownTimer', () => { const liveRegion = container.querySelector('[aria-live="polite"]'); expect(liveRegion?.textContent).toBe('Deadline passed'); }); + }); describe('reduced motion', () => { @@ -222,6 +223,7 @@ describe('CountdownTimer', () => { const countdownEl = screen.getByText(/seconds remaining/); expect(countdownEl).toHaveClass('text-destructive'); expect(countdownEl).not.toHaveClass('animate-pulse'); + }); }); }); diff --git a/tailwind.config.ts b/tailwind.config.ts index 1dacc435..e6b46d30 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -124,11 +124,16 @@ const config: Config = { to: { height: '0' } + }, + 'marquee': { + from: { transform: 'translateX(0)' }, + to: { transform: 'translateX(-100%)' } } }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out' + 'accordion-up': 'accordion-up 0.2s ease-out', + 'marquee': 'marquee 30s linear infinite' } } },