diff --git a/app/page.tsx b/app/page.tsx index 929176a..6da3dab 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { StatBar } from '@/components/landing/StatBar'; import { CorridorStrip } from '@/components/landing/CorridorStrip'; import { ComparisonTeaser } from '@/components/landing/ComparisonTeaser'; import { Faq } from '@/components/landing/Faq'; +import { LandingSection } from '@/components/landing/LandingSection'; import { registryStats } from '@/constants'; export default function HomePage() { @@ -13,52 +14,58 @@ export default function HomePage() { return (
{/* Hero */} - + + + {/* Stat bar — counts derived from the anchor registry */} -
- + {/* Explainer */} -
+

How it works

{[ @@ -116,10 +126,12 @@ export default function HomePage() {
))} -
+ {/* FAQ */} - + + + ); } diff --git a/components/landing/LandingSection.tsx b/components/landing/LandingSection.tsx new file mode 100644 index 0000000..c0eab48 --- /dev/null +++ b/components/landing/LandingSection.tsx @@ -0,0 +1,32 @@ +'use client'; + +import React from 'react'; +import { clsx } from 'clsx'; +import { useInView } from '@/hooks/useInView'; + +interface LandingSectionProps extends React.HTMLAttributes { + children: React.ReactNode; + delay?: number; +} + +export function LandingSection({ children, className, delay = 0, ...props }: LandingSectionProps) { + const [ref, inView] = useInView({ threshold: 0.1, triggerOnce: true }); + + return ( +
+ {children} +
+ ); +} diff --git a/hooks/useInView.ts b/hooks/useInView.ts new file mode 100644 index 0000000..5596087 --- /dev/null +++ b/hooks/useInView.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseInViewOptions { + threshold?: number | number[]; + rootMargin?: string; + triggerOnce?: boolean; +} + +export function useInView(options: UseInViewOptions = {}) { + const { threshold = 0.1, rootMargin = '0px', triggerOnce = true } = options; + const [inView, setInView] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }); + const ref = useRef(null); + + const thresholdString = typeof threshold === 'object' ? JSON.stringify(threshold) : threshold; + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + const element = ref.current; + if (!element) { + return; + } + + const observerThreshold = + typeof thresholdString === 'string' ? JSON.parse(thresholdString) : thresholdString; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry && entry.isIntersecting) { + setInView(true); + if (triggerOnce) { + observer.unobserve(element); + } + } else if (!triggerOnce) { + setInView(false); + } + }, + { + threshold: observerThreshold, + rootMargin, + } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [thresholdString, rootMargin, triggerOnce]); + + return [ref, inView] as const; +} diff --git a/tests/components/LandingSection.test.tsx b/tests/components/LandingSection.test.tsx new file mode 100644 index 0000000..27de01e --- /dev/null +++ b/tests/components/LandingSection.test.tsx @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { LandingSection } from '@/components/landing/LandingSection'; + +describe('LandingSection component', () => { + let observerCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null; + + beforeEach(() => { + vi.restoreAllMocks(); + observerCallback = null; + + class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + + constructor(callback: (entries: { isIntersecting: boolean }[]) => void) { + observerCallback = callback; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + }); + + it('renders children inside a section and starts hidden', () => { + render( + +
Content
+
+ ); + + const section = screen.getByTestId('landing-section'); + expect(section.tagName).toBe('SECTION'); + expect(section).toHaveTextContent('Content'); + expect(section.className).toContain('opacity-0'); + expect(section.className).toContain('translate-y-8'); + }); + + it('applies the visible state once the section intersects', () => { + render( + +
Content
+
+ ); + + act(() => { + observerCallback?.([{ isIntersecting: true }]); + }); + + const section = screen.getByTestId('landing-section'); + expect(section.className).toContain('opacity-100'); + expect(section.className).toContain('translate-y-0'); + }); + + it('supports an animation delay prop', () => { + render( + +
Content
+
+ ); + + const section = screen.getByTestId('landing-section'); + expect(section.style.transitionDelay).toBe('0ms'); + + act(() => { + observerCallback?.([{ isIntersecting: true }]); + }); + + expect(section.style.transitionDelay).toBe('150ms'); + }); + + it('stays fully visible under reduced motion', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + + render( + +
Content
+
+ ); + + const section = screen.getByTestId('landing-section'); + expect(section.className).toContain('opacity-100'); + expect(section.className).toContain('translate-y-0'); + expect(section.className).toContain('motion-reduce:transition-none'); + }); +}); diff --git a/tests/components/__snapshots__/HomePage.test.tsx.snap b/tests/components/__snapshots__/HomePage.test.tsx.snap index 5327587..3e454d9 100644 --- a/tests/components/__snapshots__/HomePage.test.tsx.snap +++ b/tests/components/__snapshots__/HomePage.test.tsx.snap @@ -5,298 +5,313 @@ exports[`HomePage > matches snapshot 1`] = ` class="space-y-8 sm:space-y-16" >
-
- - Stellar Execution Layer -
-

- The execution layer for - -

- +

+ + Off-ramp now + + + View anchors + + +
-
- -
-
-
- - + +
+
+
- 0 + + + 0 + - +
+
+
+ Anchors tracked
-
-
- Anchors tracked
-
-
- -
-
-
- - + +
+
+
- 0 + + + 0 + - +
+
+
+ Corridors live
-
-
- Corridors live
-
-
- -
-
-
- - + +
+
+
- 0 + + + 0 + - +
+
+
+ Countries reachable
-
-
- Countries reachable
-
+
-

- Supported corridors -

-
    -
  • - - - NGN - - - Nigeria - -
  • -
  • +
      - - - KES - - - Kenya - - -
    + + + NGN + + + Nigeria + +
  • +
  • + + + KES + + + Kenya + +
  • +
+
matches snapshot 1`] = `
-
+

@@ -544,7 +562,8 @@ exports[`HomePage > matches snapshot 1`] = `

matches snapshot 1`] = `

-

- Frequently asked questions -

-
-
-
- -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
-
- +
+ +
+
+
+ -
- -
-
+ + How do I contribute? + + + + + + + +
`; diff --git a/tests/hooks/useInView.test.tsx b/tests/hooks/useInView.test.tsx new file mode 100644 index 0000000..f6baaf4 --- /dev/null +++ b/tests/hooks/useInView.test.tsx @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { useInView } from '@/hooks/useInView'; + +function TestComponent({ options }: { options?: Parameters[0] }) { + const [ref, inView] = useInView(options); + + return ( +
} data-testid="target"> + {inView ? 'IN_VIEW' : 'NOT_IN_VIEW'} +
+ ); +} + +describe('useInView hook', () => { + let observeMock = vi.fn(); + let unobserveMock = vi.fn(); + let disconnectMock = vi.fn(); + let observerCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null; + + beforeEach(() => { + vi.restoreAllMocks(); + observeMock = vi.fn(); + unobserveMock = vi.fn(); + disconnectMock = vi.fn(); + observerCallback = null; + + class MockIntersectionObserver { + observe = observeMock; + unobserve = unobserveMock; + disconnect = disconnectMock; + + constructor(callback: (entries: { isIntersecting: boolean }[]) => void) { + observerCallback = callback; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + }); + + it('initializes as not in view and starts observing', () => { + render(); + + expect(screen.getByTestId('target')).toHaveTextContent('NOT_IN_VIEW'); + expect(observeMock).toHaveBeenCalled(); + }); + + it('switches to in view when the element intersects', () => { + render(); + + act(() => { + observerCallback?.([{ isIntersecting: true }]); + }); + + expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW'); + }); + + it('stops observing after the first intersection when triggerOnce is true', () => { + render(); + + act(() => { + observerCallback?.([{ isIntersecting: true }]); + }); + + expect(unobserveMock).toHaveBeenCalled(); + }); + + it('keeps observing and toggles visibility when triggerOnce is false', () => { + render(); + + act(() => { + observerCallback?.([{ isIntersecting: true }]); + }); + expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW'); + + act(() => { + observerCallback?.([{ isIntersecting: false }]); + }); + expect(screen.getByTestId('target')).toHaveTextContent('NOT_IN_VIEW'); + }); + + it('skips the observer when reduced motion is preferred', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + + render(); + + expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW'); + expect(observeMock).not.toHaveBeenCalled(); + }); +});