diff --git a/src/App.tsx b/src/App.tsx index f7cf24a..776db55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; + import TrustStrip from './components/TrustStrip'; import PartnerStrip from './components/PartnerStrip'; import { ThemeProvider } from './context/ThemeContext'; diff --git a/src/__tests__/IntegrationsCarousel.test.tsx b/src/__tests__/IntegrationsCarousel.test.tsx new file mode 100644 index 0000000..d4f7dc0 --- /dev/null +++ b/src/__tests__/IntegrationsCarousel.test.tsx @@ -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/', () => { + mockReducedMotion(true); + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + const track = screen.getByTestId('integrations-carousel'); + expect(track.style.minHeight).toBe('188px'); + }); + + it('pauses auto-rotation while hovered', () => { + mockReducedMotion(false); + vi.useFakeTimers(); + + render( + + + , + ); + + 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( + + + + + , + ); + + 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( + + + } /> + + , + ); + + expect(screen.getByRole('heading', { level: 1, name: partner.name })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /visit website/i })).toHaveAttribute( + 'href', + partner.website, + ); + }); +}); diff --git a/src/components/IntegrationsCarousel.tsx b/src/components/IntegrationsCarousel.tsx new file mode 100644 index 0000000..b23c595 --- /dev/null +++ b/src/components/IntegrationsCarousel.tsx @@ -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 ( + +
+ {item.logo ? ( + + ) : ( + + )} + + {item.category} + +
+ +
+

+ {item.name} +

+

+ {item.tagline} +

+
+ + + {t('integrationsCarousel.view')} → + + + ); +} + +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 ( +
+
+
+
+ + {t('integrationsCarousel.eyebrow')} + +

+ {t('integrationsCarousel.heading')} +

+

+ {t('integrationsCarousel.description')} +

+
+ + {t('integrationsCarousel.viewAll')} + + +
+ + {reducedMotion ? ( +
+ {integrations.map((item) => ( + + ))} +
+ ) : ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocusCapture={() => setPaused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setPaused(false); + } + }} + > +
+ {/* Duplicate once so the strip never looks empty mid-rotation. */} + {[...integrations, ...integrations].map((item, index) => ( + + ))} +
+ +
+ {integrations.map((item, index) => ( +
+
+ )} +
+
+ ); +} diff --git a/src/data/integrations.json b/src/data/integrations.json new file mode 100644 index 0000000..612237a --- /dev/null +++ b/src/data/integrations.json @@ -0,0 +1,82 @@ +{ + "featured": [ + { + "slug": "freighter", + "name": "Freighter", + "tagline": "Stellar-native browser wallet", + "description": "Non-custodial browser extension used across the Stellar ecosystem. Wraith plugs into Freighter for stealth payment signing and discovery.", + "logo": "/logos/stellar-partners/freighter.svg", + "logoWidth": 32, + "category": "Wallet", + "chains": ["Stellar"], + "website": "https://freighter.app" + }, + { + "slug": "lobstr", + "name": "LOBSTR", + "tagline": "Wallet and exchange on Stellar", + "description": "Mobile-first Stellar wallet and exchange. Featured for stealth-ready receive flows and announcement scanning.", + "logo": "/logos/stellar-partners/lobstr.svg", + "logoWidth": 23, + "category": "Wallet", + "chains": ["Stellar"], + "website": "https://lobstr.co" + }, + { + "slug": "albedo", + "name": "Albedo", + "tagline": "Identity and transaction signer", + "description": "Secure signer for Stellar apps. Enables Wraith demos and partners to request signatures without holding keys.", + "logo": "/logos/stellar-partners/albedo.svg", + "logoWidth": 32, + "category": "Signer", + "chains": ["Stellar"], + "website": "https://albedo.link" + }, + { + "slug": "xbull", + "name": "xBull", + "tagline": "Desktop and mobile Stellar wallet", + "description": "Feature-rich wallet for desktop and mobile. Integration path for stealth address receive and scan UX.", + "logo": "/logos/stellar-partners/xbull.svg", + "logoWidth": 32, + "category": "Wallet", + "chains": ["Stellar"], + "website": "https://xbull.app" + }, + { + "slug": "drips", + "name": "Drips", + "tagline": "Continuous funding infrastructure", + "description": "Token streaming and continuous funding. Wraith Wave contributions and partner bounties are routed through Drips.", + "logo": "/logos/stellar-partners/drips.svg", + "logoWidth": 32, + "category": "Funding", + "chains": ["Ethereum", "Stellar"], + "website": "https://www.drips.network" + }, + { + "slug": "stellar", + "name": "Stellar", + "tagline": "Protocol and ecosystem support", + "description": "Foundational Stellar Development Foundation support via the Stellar Wave program — memo-enabled stealth payments on Stellar.", + "logo": "/logos/stellar-partners/sdf.svg", + "logoWidth": 32, + "category": "Protocol", + "chains": ["Stellar"], + "website": "https://stellar.org" + }, + { + "slug": "horizen", + "name": "Horizen", + "tagline": "EVM stealth pilot network", + "description": "Pilot integration bringing Wraith stealth addresses to the Horizen EON ecosystem with live testnet demos.", + "logo": null, + "logoInitials": "HZ", + "logoWidth": 32, + "category": "Chain", + "chains": ["Horizen", "EVM"], + "website": "https://horizen.io" + } + ] +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 31828a2..4cf4e5f 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -344,5 +344,23 @@ "description": "Historias de producción de equipos usando Wraith.", "viewAll": "VER TODOS", "readMore": "Leer Más" + }, + "integrationsCarousel": { + "ariaLabel": "Integraciones destacadas", + "eyebrow": "ECOSISTEMA", + "heading": "Integraciones destacadas.", + "description": "Wallets, cadenas y socios de financiación ya conectados a Wraith — explora cada integración.", + "viewAll": "VER ECOSISTEMA", + "view": "Ver", + "dotsLabel": "Posición del carrusel", + "goTo": "Mostrar {{name}}" + }, + "ecosystem": { + "eyebrow": "ECOSISTEMA", + "heading": "Integraciones y socios.", + "description": "Explora las wallets, cadenas e infraestructura integradas con Wraith Protocol.", + "backToAll": "Volver al ecosistema", + "visitWebsite": "Visitar sitio", + "notFound": "Integración no encontrada" } } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..eb76073 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,48 @@ +import { lazy, Suspense } from 'react'; +import Header from '../components/Header'; +import Hero from '../components/Hero'; +import Features from '../components/Features'; +import TrustStrip from '../components/TrustStrip'; + +const Architecture = lazy(() => import('../components/Architecture')); +const ForDevelopers = lazy(() => import('../components/ForDevelopers')); +const Chains = lazy(() => import('../components/Chains')); +const StellarMetrics = lazy(() => import('../components/StellarMetrics')); +const Compare = lazy(() => import('../components/Compare')); +const Showcase = lazy(() => import('../components/Showcase')); +const IntegrationsCarousel = lazy(() => import('../components/IntegrationsCarousel')); +const CaseStudiesStrip = lazy(() => import('../components/CaseStudiesStrip')); +const EcosystemPartners = lazy(() => import('../components/EcosystemPartners')); +const CtaStrip = lazy(() => import('../components/CtaStrip')); +const Footer = lazy(() => import('../components/Footer')); + +export default function Home() { + return ( +
+ + Skip to content + +
+
+ + + + + + + + + + + + + + + +
+ +
+ ); +}