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
5 changes: 2 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Header from './components/Header';
import Hero from './components/Hero';
import Features from './components/Features';
import Layout from './components/Layout';
import Home from './pages/Home';

Check failure on line 4 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

Import declaration conflicts with local declaration of 'Home'.

import TrustStrip from './components/TrustStrip';
import PartnerStrip from './components/PartnerStrip';
import { ThemeProvider } from './context/ThemeContext';
Expand Down Expand Up @@ -39,11 +38,11 @@
<a href="#main-content" className="skip-link">
Skip to content
</a>
<Header />

Check failure on line 41 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build

src/__tests__/a11y.test.tsx > keyboard navigation > supports keyboard navigation on the homepage

ReferenceError: Header is not defined ❯ Home src/App.tsx:41:8 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 41 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build

src/__tests__/a11y.test.tsx > page accessibility > has no axe violations on the homepage page

ReferenceError: Header is not defined ❯ Home src/App.tsx:41:8 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 41 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

Cannot find name 'Header'. Did you mean 'Headers'?
<main id="main-content" tabIndex={-1}>
<Hero />

Check failure on line 43 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

Cannot find name 'Hero'.
<TrustStrip />
<Features />

Check failure on line 45 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lighthouse Audit

Cannot find name 'Features'.
<Suspense fallback={null}>
<StealthAnimation />
<Architecture />
Expand Down
151 changes: 151 additions & 0 deletions src/__tests__/IntegrationsCarousel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Suspense } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import '../i18n';
import IntegrationsCarousel from '../components/IntegrationsCarousel';
import Home from '../pages/Home';
import Ecosystem from '../pages/Ecosystem';
import { featured } from '../data/integrations.json';

function mockReducedMotion(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion') ? matches : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
mockReducedMotion(false);
});

describe('IntegrationsCarousel', () => {
it('renders featured integration cards that link to /ecosystem/<slug>', () => {
mockReducedMotion(true);
render(
<MemoryRouter>
<IntegrationsCarousel />
</MemoryRouter>,
);

expect(screen.getByRole('heading', { name: /featured integrations/i })).toBeInTheDocument();

for (const item of featured) {
const link = screen.getByRole('link', {
name: (_name, element) => element.querySelector('h3')?.textContent === item.name,
});
expect(link).toHaveAttribute('href', `/ecosystem/${item.slug}`);
}
});

it('shows a static row when prefers-reduced-motion is set', () => {
mockReducedMotion(true);
render(
<MemoryRouter>
<IntegrationsCarousel />
</MemoryRouter>,
);

expect(screen.getByTestId('integrations-static-row')).toBeInTheDocument();
expect(screen.queryByTestId('integrations-carousel')).not.toBeInTheDocument();
});

it('shows the rotating carousel when motion is allowed', () => {
mockReducedMotion(false);
render(
<MemoryRouter>
<IntegrationsCarousel />
</MemoryRouter>,
);

expect(screen.getByTestId('integrations-carousel')).toBeInTheDocument();
expect(screen.queryByTestId('integrations-static-row')).not.toBeInTheDocument();
});

it('reserves a fixed min-height to avoid CLS', () => {
mockReducedMotion(false);
render(
<MemoryRouter>
<IntegrationsCarousel />
</MemoryRouter>,
);

const track = screen.getByTestId('integrations-carousel');
expect(track.style.minHeight).toBe('188px');
});

it('pauses auto-rotation while hovered', () => {
mockReducedMotion(false);
vi.useFakeTimers();

render(
<MemoryRouter>
<IntegrationsCarousel />
</MemoryRouter>,
);

const track = screen.getByTestId('integrations-carousel');
const freighterTab = screen.getByRole('tab', { name: /show freighter/i });
const lobstrTab = screen.getByRole('tab', { name: /show lobstr/i });

expect(freighterTab).toHaveAttribute('aria-selected', 'true');

act(() => {
vi.advanceTimersByTime(4500);
});
expect(lobstrTab).toHaveAttribute('aria-selected', 'true');

fireEvent.mouseEnter(track);
act(() => {
vi.advanceTimersByTime(20000);
});
expect(lobstrTab).toHaveAttribute('aria-selected', 'true');
});
});

describe('Home integrations strip', () => {
it('renders the integrations carousel on Home', async () => {
mockReducedMotion(true);
render(
<MemoryRouter>
<Suspense fallback={null}>
<Home />
</Suspense>
</MemoryRouter>,
);

expect(
await screen.findByRole('heading', { name: /featured integrations/i }, { timeout: 8000 }),
).toBeInTheDocument();
});
});

describe('Ecosystem partner routes', () => {
it('resolves each featured partner slug', () => {
const partner = featured[0]!;
render(
<MemoryRouter initialEntries={[`/ecosystem/${partner.slug}`]}>
<Routes>
<Route path="/ecosystem/:slug" element={<Ecosystem />} />
</Routes>
</MemoryRouter>,
);

expect(screen.getByRole('heading', { level: 1, name: partner.name })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /visit website/i })).toHaveAttribute(
'href',
partner.website,
);
});
});
192 changes: 192 additions & 0 deletions src/components/IntegrationsCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { featured } from '../data/integrations.json';

export type Integration = {
slug: string;
name: string;
tagline: string;
description: string;
logo: string | null;
logoInitials?: string;
logoWidth: number;
category: string;
chains: string[];
website: string;
};

const integrations = featured as Integration[];
const ROTATE_MS = 4500;
/** Fixed card footprint — prevents CLS when the strip mounts or rotates. */
const CARD_MIN_HEIGHT = 188;
const CARD_WIDTH = 280;
const CARD_GAP = 16;
const SLIDE_STEP = CARD_WIDTH + CARD_GAP;

function IntegrationCard({ item }: { item: Integration }) {
const { t } = useTranslation();

return (
<Link
to={`/ecosystem/${item.slug}`}
className="group flex h-full shrink-0 flex-col gap-4 border border-outline-variant-30 bg-surface-container p-5 transition-colors duration-150 hover:border-outline hover:bg-surface-bright"
style={{ minHeight: CARD_MIN_HEIGHT, width: CARD_WIDTH }}
>
<div className="flex h-10 items-center justify-between gap-3">
{item.logo ? (
<img
src={item.logo}
alt=""
width={item.logoWidth}
height={32}
className="h-8 w-auto object-contain opacity-70 grayscale transition-all duration-150 group-hover:opacity-100 group-hover:grayscale-0"
/>
) : (
<span
className="flex h-8 w-8 items-center justify-center border border-outline-variant bg-surface font-heading text-[11px] font-bold text-primary"
aria-hidden="true"
>
{item.logoInitials ?? item.name.slice(0, 2).toUpperCase()}
</span>
)}
<span className="font-mono text-[9px] font-semibold uppercase tracking-[1.2px] text-outline">
{item.category}
</span>
</div>

<div className="flex flex-1 flex-col gap-1.5">
<h3 className="font-heading text-[15px] font-semibold tracking-[-0.2px] text-on-surface group-hover:text-primary">
{item.name}
</h3>
<p className="font-body text-[12px] leading-[1.5] text-on-surface-variant">
{item.tagline}
</p>
</div>

<span className="font-mono text-[10px] font-semibold uppercase tracking-[1.2px] text-outline transition-colors duration-150 group-hover:text-primary">
{t('integrationsCarousel.view')} →
</span>
</Link>
);
}

export default function IntegrationsCarousel() {
const { t } = useTranslation();
const [activeIndex, setActiveIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [reducedMotion, setReducedMotion] = useState(() =>
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
);

useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => setReducedMotion(media.matches);
onChange();
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, []);

useEffect(() => {
if (reducedMotion || paused || integrations.length <= 1) return;

const id = window.setInterval(() => {
setActiveIndex((current) => (current + 1) % integrations.length);
}, ROTATE_MS);

return () => window.clearInterval(id);
}, [paused, reducedMotion]);

if (integrations.length === 0) return null;

return (
<section
aria-label={t('integrationsCarousel.ariaLabel')}
className="border-t border-outline-variant-30 px-6 py-24 md:px-12"
>
<div className="mx-auto flex max-w-[1344px] flex-col gap-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-3">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[2px] text-outline">
{t('integrationsCarousel.eyebrow')}
</span>
<h2 className="font-heading text-[28px] font-bold leading-[1.1] tracking-[-1.2px] text-on-surface sm:text-[40px]">
{t('integrationsCarousel.heading')}
</h2>
<p className="max-w-[640px] font-body text-base leading-[1.6] text-on-surface-variant">
{t('integrationsCarousel.description')}
</p>
</div>
<Link
to="/ecosystem"
className="group flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[1.5px] text-outline transition-colors duration-150 hover:text-primary"
>
{t('integrationsCarousel.viewAll')}
<span className="transition-transform duration-150 group-hover:translate-x-0.5">→</span>
</Link>
</div>

{reducedMotion ? (
<div
className="flex gap-4 overflow-x-auto pb-1"
data-testid="integrations-static-row"
style={{ minHeight: CARD_MIN_HEIGHT }}
>
{integrations.map((item) => (
<IntegrationCard key={item.slug} item={item} />
))}
</div>
) : (
<div
className="relative overflow-hidden"
data-testid="integrations-carousel"
style={{ minHeight: CARD_MIN_HEIGHT }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false);
}
}}
>
<div
className="flex gap-4 transition-transform duration-500 ease-out"
style={{
transform: `translateX(-${activeIndex * SLIDE_STEP}px)`,
}}
aria-live="polite"
>
{/* Duplicate once so the strip never looks empty mid-rotation. */}
{[...integrations, ...integrations].map((item, index) => (
<IntegrationCard key={`${item.slug}-${index}`} item={item} />
))}
</div>

<div
className="mt-6 flex items-center gap-2"
role="tablist"
aria-label={t('integrationsCarousel.dotsLabel')}
>
{integrations.map((item, index) => (
<button
key={item.slug}
type="button"
role="tab"
aria-selected={index === activeIndex}
aria-label={t('integrationsCarousel.goTo', { name: item.name })}
className={`h-1.5 w-6 border-0 transition-colors duration-150 ${
index === activeIndex ? 'bg-primary' : 'bg-outline-variant'
}`}
onClick={() => setActiveIndex(index)}
/>
))}
</div>
</div>
)}
</div>
</section>
);
}
Loading
Loading