Skip to content
Merged
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
94 changes: 53 additions & 41 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,59 +6,66 @@ 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() {
const stats = registryStats();
return (
<div className="space-y-8 sm:space-y-16">
{/* Hero */}
<Hero />
<LandingSection delay={0}>
<Hero />
</LandingSection>

{/* Stat bar — counts derived from the anchor registry */}
<StatBar
stats={[
{
icon: (
<Landmark
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.anchors,
label: 'Anchors tracked',
},
{
icon: (
<Route
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.corridors,
label: 'Corridors live',
},
{
icon: (
<Globe
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.countries,
label: 'Countries reachable',
},
]}
/>
<LandingSection delay={100}>
<StatBar
stats={[
{
icon: (
<Landmark
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.anchors,
label: 'Anchors tracked',
},
{
icon: (
<Route
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.corridors,
label: 'Corridors live',
},
{
icon: (
<Globe
className="h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
),
value: stats.countries,
label: 'Countries reachable',
},
]}
/>
</LandingSection>

{/* Supported corridors */}
<CorridorStrip />
<LandingSection delay={150}>
<CorridorStrip />
</LandingSection>

{/* Comparison teaser */}
<ComparisonTeaser />

{/* Module card */}
<section>
<LandingSection delay={200}>
<h2 className="mb-6 text-xl font-semibold text-gray-900 dark:text-white">
Start executing
</h2>
Expand All @@ -82,10 +89,13 @@ export default function HomePage() {
</Card>
</Link>
</div>
</section>
</LandingSection>

{/* Explainer */}
<section className="rounded-xl border border-gray-200 p-4 dark:border-gray-700 dark:bg-gray-800/50 sm:p-6">
<LandingSection
delay={300}
className="rounded-xl border border-gray-200 p-4 dark:border-gray-700 dark:bg-gray-800/50 sm:p-6"
>
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">How it works</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{[
Expand Down Expand Up @@ -116,10 +126,12 @@ export default function HomePage() {
</div>
))}
</div>
</section>
</LandingSection>

{/* FAQ */}
<Faq />
<LandingSection delay={350}>
<Faq />
</LandingSection>
</div>
);
}
32 changes: 32 additions & 0 deletions components/landing/LandingSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import React from 'react';
import { clsx } from 'clsx';
import { useInView } from '@/hooks/useInView';

interface LandingSectionProps extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode;
delay?: number;
}

export function LandingSection({ children, className, delay = 0, ...props }: LandingSectionProps) {
const [ref, inView] = useInView({ threshold: 0.1, triggerOnce: true });

return (
<section
ref={ref}
className={clsx(
'transition-all duration-700 ease-out',
inView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
'motion-reduce:transition-none motion-reduce:transform-none motion-reduce:opacity-100',
className
)}
style={{
transitionDelay: inView && delay ? `${delay}ms` : '0ms',
}}
{...props}
>
{children}
</section>
);
}
65 changes: 65 additions & 0 deletions hooks/useInView.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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;
}
112 changes: 112 additions & 0 deletions tests/components/LandingSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LandingSection data-testid="landing-section">
<div>Content</div>
</LandingSection>
);

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(
<LandingSection data-testid="landing-section">
<div>Content</div>
</LandingSection>
);

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(
<LandingSection data-testid="landing-section" delay={150}>
<div>Content</div>
</LandingSection>
);

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(
<LandingSection data-testid="landing-section">
<div>Content</div>
</LandingSection>
);

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');
});
});
Loading
Loading