diff --git a/package.json b/package.json index faab537..4989ee2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "github-slugger": "^2.0.0", "husky": "^9.1.7", "jsdom": "^29.1.1", - "prettier": "^3.8.2", + "prettier": "3.8.2", "rehype-slug": "^6.0.0", "remark-frontmatter": "^5.0.0", "remark-mdx-frontmatter": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a76f71..1a8867e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,7 +88,7 @@ importers: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@1.8.0) prettier: - specifier: ^3.8.2 + specifier: 3.8.2 version: 3.8.2 rehype-slug: specifier: ^6.0.0 diff --git a/public/feed.xml b/public/feed.xml index 3c65f31..d00b5e3 100644 --- a/public/feed.xml +++ b/public/feed.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/announcements.xml b/public/feed/tag/announcements.xml index 6db8fb7..d51b432 100644 --- a/public/feed/tag/announcements.xml +++ b/public/feed/tag/announcements.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/announcements Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml index fdad9d3..4881df4 100644 --- a/public/feed/tag/cryptography.xml +++ b/public/feed/tag/cryptography.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/cryptography Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/privacy.xml b/public/feed/tag/privacy.xml index 17323f2..c9a22ef 100644 --- a/public/feed/tag/privacy.xml +++ b/public/feed/tag/privacy.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/privacy Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/sdk.xml b/public/feed/tag/sdk.xml index 670f5c8..32680ad 100644 --- a/public/feed/tag/sdk.xml +++ b/public/feed/tag/sdk.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/sdk Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/stealth-payments.xml b/public/feed/tag/stealth-payments.xml index a1398bd..9cd900c 100644 --- a/public/feed/tag/stealth-payments.xml +++ b/public/feed/tag/stealth-payments.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/stealth-payments Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/wave-6.xml b/public/feed/tag/wave-6.xml index 8bcaf8e..56f9bee 100644 --- a/public/feed/tag/wave-6.xml +++ b/public/feed/tag/wave-6.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-6 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/feed/tag/wave-7.xml b/public/feed/tag/wave-7.xml index 00d57bc..dc3c517 100644 --- a/public/feed/tag/wave-7.xml +++ b/public/feed/tag/wave-7.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-7 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 28 Sep 2026 22:48:43 GMT + Sun, 04 Oct 2026 01:17:44 GMT diff --git a/public/sitemap.xml b/public/sitemap.xml index ab6be3e..94e8577 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,205 +2,205 @@ https://usewraith.xyz - 2026-09-28 + 2026-10-04 daily 1.0 https://usewraith.xyz/faq - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/privacy - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/use-cases - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/use-cases/calculator - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/roadmap - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/case-studies - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/stellar - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/careers - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/press - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/case-studies/payroll-processor - 2026-09-28 + 2026-10-04 weekly 0.7 https://usewraith.xyz/blog/author/lena-vogt - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/privacy - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/stealth-payments - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/cryptography - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/announcements - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/wave-7 - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/wave-6 - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/tag/sdk - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/about - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/privacy-by-default - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/stealth-addresses-explained - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/blog/wave-7-kickoff - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/chains - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/contributors - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/ecosystem - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/governance - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/grants - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/newsletter - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/security - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/status - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/threat-model - 2026-09-28 + 2026-10-04 weekly 0.8 https://usewraith.xyz/vitals - 2026-09-28 + 2026-10-04 weekly 0.8 diff --git a/scripts/gen-sitemap.mjs b/scripts/gen-sitemap.mjs index b38478e..efe8abe 100644 --- a/scripts/gen-sitemap.mjs +++ b/scripts/gen-sitemap.mjs @@ -24,6 +24,14 @@ const knownRoutes = [ '/press', ]; +/** + * Pages that have locale-prefixed alternates. + * Must stay in sync with LOCALE_URL_PREFIX in src/utils/seo.ts and the + * locale-prefixed entries in App.tsx. + */ +const LOCALE_PREFIXED_PAGES = ['/', '/stellar', '/grants', '/blog', '/case-studies']; +const SUPPORTED_LOCALE_PREFIXES = ['/es', '/pt']; + /** * Dynamically extract routes from case studies data */ @@ -121,8 +129,13 @@ function generateSitemap() { const distRoutes = existsSync(distDir) ? getDistRoutes(distDir) : []; const tagRoutes = getBlogTagRoutes(); + // Build locale-prefixed alternates for pages that have translations. + const localeRoutes = SUPPORTED_LOCALE_PREFIXES.flatMap((prefix) => + LOCALE_PREFIXED_PAGES.map((page) => (page === '/' ? prefix : `${prefix}${page}`)), + ); + const allRoutes = Array.from( - new Set([...knownRoutes, ...csRoutes, ...distRoutes, ...tagRoutes]), + new Set([...knownRoutes, ...csRoutes, ...distRoutes, ...tagRoutes, ...localeRoutes]), ).filter((r) => r && r !== '/404' && !r.includes('/staging') && !r.includes('/preview')); const today = new Date().toISOString().split('T')[0]; @@ -132,8 +145,10 @@ function generateSitemap() { ${allRoutes .map((r) => { const loc = `${siteUrl}${r === '/' ? '' : r}`; - const priority = r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'; - const changefreq = r === '/' ? 'daily' : 'weekly'; + const isLocaleRoot = r === '/es' || r === '/pt'; + const priority = + r === '/' || isLocaleRoot ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'; + const changefreq = r === '/' || isLocaleRoot ? 'daily' : 'weekly'; return ` ${loc} ${today} diff --git a/scripts/og.ts b/scripts/og.ts index d072694..af4358d 100644 --- a/scripts/og.ts +++ b/scripts/og.ts @@ -188,13 +188,35 @@ function patchMetadata(html: string, config: OgImageJob): string { ); } + // Canonical + hreflang alternates + // Remove any previously injected canonical/hreflang links so we don't duplicate on re-runs. + patched = patched.replace(/\s*]*>\s*/g, '\n '); + patched = patched.replace(/\s*]*>\s*/g, '\n '); + + const SITE_URL = 'https://usewraith.xyz'; + const cleanPath = config.routePath === '/' ? '' : config.routePath; + const enUrl = `${SITE_URL}${cleanPath}`; + const esUrl = `${SITE_URL}/es${cleanPath}`; + const ptUrl = `${SITE_URL}/pt${cleanPath}`; + + const hreflangLinks = [ + ``, + ``, + ``, + ``, + ``, + ].join('\n '); + + // Inject before + patched = patched.replace('', ` ${hreflangLinks}\n `); + // Breadcrumb JSON-LD const breadcrumbListElement: any[] = [ { '@type': 'ListItem', position: 1, name: 'Home', - item: 'https://usewraith.xyz', + item: SITE_URL, }, ]; @@ -203,7 +225,7 @@ function patchMetadata(html: string, config: OgImageJob): string { '@type': 'ListItem', position: 2, name: config.title, - item: `https://usewraith.xyz${config.routePath}`, + item: enUrl, }); } diff --git a/src/App.tsx b/src/App.tsx index 43a8e16..e07440a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ -import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { lazy, Suspense, useEffect } from 'react'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; import Header from './components/Header'; import Hero from './components/Hero'; import Features from './components/Features'; @@ -7,6 +8,8 @@ import Layout from './components/Layout'; import TrustStrip from './components/TrustStrip'; import PartnerStrip from './components/PartnerStrip'; import { ThemeProvider } from './context/ThemeContext'; +import { usePageSeo } from './utils/seo'; +import { changeLocale, type Locale } from './i18n'; // Lazy load below-the-fold homepage components const StealthAnimation = lazy(() => import('./components/StealthAnimation')); @@ -44,9 +47,51 @@ const Blog = lazy(() => import('./pages/Blog')); const Ecosystem = lazy(() => import('./pages/Ecosystem')); const ChainsPage = lazy(() => import('./pages/Chains')); +/** + * Sets the active locale based on the URL prefix, then renders `children`. + * Used by locale-prefixed routes like /es/* and /pt/*. + */ +function LocaleScope({ locale, children }: { locale: Locale; children: React.ReactNode }) { + useEffect(() => { + changeLocale(locale); + }, [locale]); + return <>{children}; +} + +/** + * Synchronises the i18n language with the URL. + * When the user navigates to an unprefixed (English) route — i.e. a path that + * does NOT start with /es or /pt — the locale is reset to English so that the + * language state never lingers from a previous non-English visit. + */ +function LocaleSync() { + const { pathname } = useLocation(); + useEffect(() => { + const isLocalePrefixed = pathname.startsWith('/es') || pathname.startsWith('/pt'); + if (!isLocalePrefixed) { + changeLocale('en'); + } + // locale-prefixed routes are handled by LocaleScope; no action needed here. + }, [pathname]); + return null; +} + function Home() { + const seo = usePageSeo('home'); + return (
+ + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + + + Skip to content @@ -80,6 +125,7 @@ export default function App() { return ( + } /> @@ -215,6 +261,108 @@ export default function App() { } /> } /> + + {/* ── Locale-prefixed routes (/es/*, /pt/*) ──────────────────── + These are the distinct, crawlable URLs referenced by hreflang + alternates. Each route activates the matching locale then + renders the same page component as its English counterpart. */} + {(['es', 'pt'] as const).map((locale) => ( + <> + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + ))} diff --git a/src/__tests__/routing.test.tsx b/src/__tests__/routing.test.tsx new file mode 100644 index 0000000..46b9d79 --- /dev/null +++ b/src/__tests__/routing.test.tsx @@ -0,0 +1,673 @@ +/** + * Route-navigation unit tests. + * + * Covers: + * - useLocalePath produces correct prefixes for en, es, and pt + * - useLocalePath does not double-prefix already-prefixed paths + * - useLocalePath passes through external URLs and hash-only links unchanged + * - Header navigation links carry the locale prefix when the locale is es or pt + * - Header navigation links are unprefixed when the locale is en + * - LocaleSync resets the locale to English when navigating to an unprefixed route + * - LocaleSync leaves the locale alone when navigating to a locale-prefixed route + * (locale is set by LocaleScope, not LocaleSync) + */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import i18n from '../i18n'; +import { useLocalePath, hasLocalizedRoute } from '../hooks/useLocalePath'; +import { renderHook } from '@testing-library/react'; +import { ThemeProvider } from '../context/ThemeContext'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Renders a hook inside a MemoryRouter (required for any hook that calls useTranslation + * when i18next is initialised with react-i18next). */ +function renderHookInRouter(hook: () => T, initialEntries: string[] = ['/']) { + return renderHook(hook, { + wrapper: ({ children }) => ( + {children} + ), + }); +} + +// ─── useLocalePath ──────────────────────────────────────────────────────────── + +describe('useLocalePath', () => { + afterEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('returns paths unchanged when locale is en', async () => { + await i18n.changeLanguage('en'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('/grants')).toBe('/grants'); + expect(result.current('/blog')).toBe('/blog'); + expect(result.current('/')).toBe('/'); + }); + + it('prepends /es when locale is es', async () => { + await i18n.changeLanguage('es'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('/grants')).toBe('/es/grants'); + expect(result.current('/blog')).toBe('/es/blog'); + expect(result.current('/stellar')).toBe('/es/stellar'); + expect(result.current('/')).toBe('/es/'); + }); + + it('prepends /pt when locale is pt', async () => { + await i18n.changeLanguage('pt'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('/grants')).toBe('/pt/grants'); + expect(result.current('/case-studies')).toBe('/pt/case-studies'); + expect(result.current('/case-studies/defi-privacy')).toBe('/pt/case-studies/defi-privacy'); + }); + + it('does not double-prefix an already-prefixed /es path', async () => { + await i18n.changeLanguage('es'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('/es/grants')).toBe('/es/grants'); + }); + + it('does not double-prefix an already-prefixed /pt path', async () => { + await i18n.changeLanguage('pt'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('/pt/blog')).toBe('/pt/blog'); + }); + + it('passes through external URLs unchanged', async () => { + await i18n.changeLanguage('es'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('https://docs.usewraith.xyz')).toBe('https://docs.usewraith.xyz'); + }); + + it('passes through hash-only links unchanged', async () => { + await i18n.changeLanguage('pt'); + const { result } = renderHookInRouter(() => useLocalePath()); + expect(result.current('#compare')).toBe('#compare'); + }); +}); + +// ─── hasLocalizedRoute ─────────────────────────────────────────────────────────── + +describe('hasLocalizedRoute', () => { + it('returns true for routes with localized versions', () => { + expect(hasLocalizedRoute('/')).toBe(true); + expect(hasLocalizedRoute('/blog')).toBe(true); + expect(hasLocalizedRoute('/blog/some-post')).toBe(true); + expect(hasLocalizedRoute('/blog/author/some-author')).toBe(true); + expect(hasLocalizedRoute('/blog/tag/some-tag')).toBe(true); + expect(hasLocalizedRoute('/stellar')).toBe(true); + expect(hasLocalizedRoute('/grants')).toBe(true); + expect(hasLocalizedRoute('/case-studies')).toBe(true); + expect(hasLocalizedRoute('/case-studies/some-case')).toBe(true); + }); + + it('returns false for routes without localized versions', () => { + expect(hasLocalizedRoute('/privacy')).toBe(false); + expect(hasLocalizedRoute('/about')).toBe(false); + expect(hasLocalizedRoute('/vitals')).toBe(false); + expect(hasLocalizedRoute('/security')).toBe(false); + expect(hasLocalizedRoute('/careers')).toBe(false); + expect(hasLocalizedRoute('/governance')).toBe(false); + expect(hasLocalizedRoute('/faq')).toBe(false); + expect(hasLocalizedRoute('/newsletter')).toBe(false); + expect(hasLocalizedRoute('/use-cases')).toBe(false); + expect(hasLocalizedRoute('/roadmap')).toBe(false); + expect(hasLocalizedRoute('/threat-model')).toBe(false); + expect(hasLocalizedRoute('/status')).toBe(false); + expect(hasLocalizedRoute('/contributors')).toBe(false); + expect(hasLocalizedRoute('/chains')).toBe(false); + expect(hasLocalizedRoute('/ecosystem')).toBe(false); + }); + + it('returns false for external URLs and hash links', () => { + expect(hasLocalizedRoute('https://example.com')).toBe(false); + expect(hasLocalizedRoute('#compare')).toBe(false); + expect(hasLocalizedRoute('')).toBe(false); + }); +}); + +// ─── Footer locale-aware navigation links ──────────────────────────────────────── + +describe('Footer locale-aware navigation links', () => { + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + afterEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('blog link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const blogLinks = screen.getAllByRole('link', { name: /blog/i }); + expect(blogLinks.length).toBeGreaterThan(0); + for (const link of blogLinks) { + expect(link).toHaveAttribute('href', '/blog'); + } + }); + + it('case-studies link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const caseStudiesLinks = screen.getAllByRole('link', { name: /case studies/i }); + expect(caseStudiesLinks.length).toBeGreaterThan(0); + for (const link of caseStudiesLinks) { + expect(link).toHaveAttribute('href', '/case-studies'); + } + }); + + it('stellar link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const stellarLinks = screen.getAllByRole('link', { name: /stellar integration/i }); + expect(stellarLinks.length).toBeGreaterThan(0); + for (const link of stellarLinks) { + expect(link).toHaveAttribute('href', '/stellar'); + } + }); + + it('careers link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const careersLinks = screen.getAllByRole('link', { name: /careers/i }); + expect(careersLinks.length).toBeGreaterThan(0); + for (const link of careersLinks) { + expect(link).toHaveAttribute('href', '/careers'); + } + }); + + it('about link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const aboutLinks = screen.getAllByRole('link', { name: /^about$/i }); + expect(aboutLinks.length).toBeGreaterThan(0); + for (const link of aboutLinks) { + expect(link).toHaveAttribute('href', '/about'); + } + }); + + it('governance link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const governanceLinks = screen.getAllByRole('link', { name: /governance/i }); + expect(governanceLinks.length).toBeGreaterThan(0); + for (const link of governanceLinks) { + expect(link).toHaveAttribute('href', '/governance'); + } + }); + + it('blog link has /es prefix when locale is es', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const blogLinks = screen.getAllByRole('link', { name: /blog/i }); + expect(blogLinks.length).toBeGreaterThan(0); + for (const link of blogLinks) { + expect(link).toHaveAttribute('href', '/es/blog'); + } + }); + + it('case-studies link has /es prefix when locale is es', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const caseStudiesLinks = screen.getAllByRole('link', { name: /casos de estudio/i }); + expect(caseStudiesLinks.length).toBeGreaterThan(0); + for (const link of caseStudiesLinks) { + expect(link).toHaveAttribute('href', '/es/case-studies'); + } + }); + + it('stellar link has /es prefix when locale is es', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const stellarLinks = screen.getAllByRole('link', { name: /integraci/i }); + expect(stellarLinks.length).toBeGreaterThan(0); + for (const link of stellarLinks) { + expect(link).toHaveAttribute('href', '/es/stellar'); + } + }); + + it('blog link has /pt prefix when locale is pt', async () => { + await act(async () => { + await i18n.changeLanguage('pt'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const blogLinks = screen.getAllByRole('link', { name: /blog/i }); + expect(blogLinks.length).toBeGreaterThan(0); + for (const link of blogLinks) { + expect(link).toHaveAttribute('href', '/pt/blog'); + } + }); + + it('case-studies link has /pt prefix when locale is pt', async () => { + await act(async () => { + await i18n.changeLanguage('pt'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const caseStudiesLinks = screen.getAllByRole('link', { name: /estudos de caso/i }); + expect(caseStudiesLinks.length).toBeGreaterThan(0); + for (const link of caseStudiesLinks) { + expect(link).toHaveAttribute('href', '/pt/case-studies'); + } + }); + + it('privacy link has NO prefix when locale is es (no localized version)', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const privacyLinks = screen.getAllByRole('link', { name: /privacidad/i }); + expect(privacyLinks.length).toBeGreaterThan(0); + for (const link of privacyLinks) { + expect(link).toHaveAttribute('href', '/privacy'); + } + }); + + it('about link has NO prefix when locale is pt (no localized version)', async () => { + await act(async () => { + await i18n.changeLanguage('pt'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const aboutLinks = screen.getAllByRole('link', { name: /sobre/i }); + expect(aboutLinks.length).toBeGreaterThan(0); + for (const link of aboutLinks) { + expect(link).toHaveAttribute('href', '/about'); + } + }); + + it('vitals link has NO prefix when locale is es (no localized version)', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + const vitalsLinks = screen.getAllByRole('link', { name: /web vitals/i }); + expect(vitalsLinks.length).toBeGreaterThan(0); + for (const link of vitalsLinks) { + expect(link).toHaveAttribute('href', '/vitals'); + } + }); + + it('security link has NO prefix when locale is pt (no localized version)', async () => { + await act(async () => { + await i18n.changeLanguage('pt'); + }); + const { default: Footer } = await import('../components/Footer'); + render( + + +
+ + , + ); + // Find the "Segurança" link in the trust column (href="/security"), not the resources column (external URL) + const securityLinks = screen.getAllByRole('link', { name: /^segurança$/i }); + expect(securityLinks.length).toBeGreaterThan(0); + for (const link of securityLinks) { + const href = link.getAttribute('href'); + // Only test the internal /security link, not the external docs link + if (href === '/security') { + expect(link).toHaveAttribute('href', '/security'); + return; + } + } + throw new Error('Internal /security link not found in trust column'); + }); +}); + +// ─── Header locale-aware links ──────────────────────────────────────────────── + +describe('Header locale-aware navigation links', () => { + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + afterEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('grants link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Header } = await import('../components/Header'); + render( + + +
+ + , + ); + const grantsLinks = screen.getAllByRole('link', { name: /grants/i }); + // Desktop link (there may be only one visible in jsdom since md: is not applied) + expect(grantsLinks.length).toBeGreaterThan(0); + for (const link of grantsLinks) { + expect(link).toHaveAttribute('href', '/grants'); + } + }); + + it('blog link has no prefix when locale is en', async () => { + window.history.replaceState({}, '', '/'); + const { default: Header } = await import('../components/Header'); + render( + + +
+ + , + ); + const blogLinks = screen.getAllByRole('link', { name: /^blog$/i }); + expect(blogLinks.length).toBeGreaterThan(0); + for (const link of blogLinks) { + expect(link).toHaveAttribute('href', '/blog'); + } + }); + + it('grants link has /es prefix when locale is es', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Header } = await import('../components/Header'); + const { container } = render( + + +
+ + , + ); + // Query by href since the label is translated to "Subvenciones" in Spanish + const grantsLinks = container.querySelectorAll('a[href="/es/grants"]'); + expect(grantsLinks.length).toBeGreaterThan(0); + }); + + it('blog link has /es prefix when locale is es', async () => { + await act(async () => { + await i18n.changeLanguage('es'); + }); + const { default: Header } = await import('../components/Header'); + render( + + +
+ + , + ); + const blogLinks = screen.getAllByRole('link', { name: /^blog$/i }); + expect(blogLinks.length).toBeGreaterThan(0); + for (const link of blogLinks) { + expect(link).toHaveAttribute('href', '/es/blog'); + } + }); + + it('stellar link has /pt prefix when locale is pt', async () => { + await act(async () => { + await i18n.changeLanguage('pt'); + }); + const { default: Header } = await import('../components/Header'); + render( + + +
+ + , + ); + const stellarLinks = screen.getAllByRole('link', { name: /^stellar$/i }); + expect(stellarLinks.length).toBeGreaterThan(0); + for (const link of stellarLinks) { + expect(link).toHaveAttribute('href', '/pt/stellar'); + } + }); +}); + +// ─── LocaleSync — resets locale on English (unprefixed) routes ──────────────── + +describe('LocaleSync', () => { + afterEach(async () => { + await i18n.changeLanguage('en'); + vi.restoreAllMocks(); + }); + + it('resets locale to English when navigating to an unprefixed route', async () => { + // Start with Spanish active + await act(async () => { + await i18n.changeLanguage('es'); + }); + expect(i18n.language).toBe('es'); + + // Navigate to an unprefixed English route via App + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(); + + // Wait for the page to settle — LocaleSync fires on mount + await screen.findByRole('heading', { name: /build private payments/i }); + + await waitFor(() => { + expect(i18n.language).toBe('en'); + }); + }); + + it('resets locale to English when navigating from a locale-prefixed route to an English route', async () => { + // Simulate starting on a Spanish page + await act(async () => { + await i18n.changeLanguage('es'); + }); + + // Now render directly at an English route — LocaleSync should reset + window.history.replaceState({}, '', '/blog'); + const { default: App } = await import('../App'); + render(); + + await screen.findByRole('heading', { name: /wraith protocol blog/i }); + + await waitFor(() => { + expect(i18n.language).toBe('en'); + }); + }); + + it('does NOT reset locale when the URL carries a /es prefix', async () => { + // The /es routes activate LocaleScope which sets 'es'; LocaleSync must not + // override that by resetting to 'en'. + window.history.replaceState({}, '', '/es/grants'); + const { default: App } = await import('../App'); + render(); + + await screen.findByRole('heading', { name: /build private payments/i }); + + // LocaleScope sets 'es'; i18n language should remain 'es' + await waitFor(() => { + expect(i18n.language).toBe('es'); + }); + }); + + it('does NOT reset locale when the URL carries a /pt prefix', async () => { + window.history.replaceState({}, '', '/pt/blog'); + const { default: App } = await import('../App'); + render(); + + await screen.findByRole('heading', { name: /wraith protocol blog/i }); + + await waitFor(() => { + expect(i18n.language).toBe('pt'); + }); + }); +}); + +// ─── Locale-prefixed blog author/tag routes ──────────────────────────────────── + +describe('Locale-prefixed blog author/tag routes', () => { + afterEach(async () => { + await i18n.changeLanguage('en'); + vi.restoreAllMocks(); + }); + + it('renders Blog author page at /es/blog/author/author-id with Spanish locale', async () => { + window.history.replaceState({}, '', '/es/blog/author/lena-vogt'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'es'; i18n language should remain 'es' + await waitFor(() => { + expect(i18n.language).toBe('es'); + }); + }); + + it('renders Blog author page at /pt/blog/author/author-id with Portuguese locale', async () => { + window.history.replaceState({}, '', '/pt/blog/author/wraith-team'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'pt'; i18n language should remain 'pt' + await waitFor(() => { + expect(i18n.language).toBe('pt'); + }); + }); + + it('renders Blog tag page at /es/blog/tag/stealth with Spanish locale', async () => { + window.history.replaceState({}, '', '/es/blog/tag/stealth'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'es'; i18n language should remain 'es' + await waitFor(() => { + expect(i18n.language).toBe('es'); + }); + }); + + it('renders Blog tag page at /pt/blog/tag/privacy with Portuguese locale', async () => { + window.history.replaceState({}, '', '/pt/blog/tag/privacy'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'pt'; i18n language should remain 'pt' + await waitFor(() => { + expect(i18n.language).toBe('pt'); + }); + }); + + it('renders CaseStudies detail page at /es/case-studies/payroll-processor with Spanish locale', async () => { + window.history.replaceState({}, '', '/es/case-studies/payroll-processor'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'es'; i18n language should remain 'es' + await waitFor(() => { + expect(i18n.language).toBe('es'); + }); + }); + + it('renders CaseStudies detail page at /pt/case-studies/payroll-processor with Portuguese locale', async () => { + window.history.replaceState({}, '', '/pt/case-studies/payroll-processor'); + const { default: App } = await import('../App'); + render(); + + // LocaleScope sets 'pt'; i18n language should remain 'pt' + await waitFor(() => { + expect(i18n.language).toBe('pt'); + }); + }); +}); diff --git a/src/__tests__/seo.test.tsx b/src/__tests__/seo.test.tsx new file mode 100644 index 0000000..a1b072d --- /dev/null +++ b/src/__tests__/seo.test.tsx @@ -0,0 +1,346 @@ +/** + * Tests for the locale-aware SEO metadata utilities. + * + * Covers: + * - canonicalUrl always returns the English (default) URL + * - hreflangAlternates emits en, es, pt-BR, and x-default entries all pointing + * at the same canonical URL + * - usePageSeo hook returns translated title/description for each locale + * - Stellar, Grants, Blog, and CaseStudies pages render locale-correct + * and <meta name="description"> for Spanish and Portuguese + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import i18n from '../i18n'; +import { canonicalUrl, hreflangAlternates, SITE_ORIGIN } from '../utils/seo'; + +// ─── Pure utility tests ─────────────────────────────────────────────────────── + +describe('canonicalUrl', () => { + it('returns the site origin for home', () => { + expect(canonicalUrl('home')).toBe(SITE_ORIGIN); + }); + + it('appends the correct path for stellar', () => { + expect(canonicalUrl('stellar')).toBe(`${SITE_ORIGIN}/stellar`); + }); + + it('appends the correct path for grants', () => { + expect(canonicalUrl('grants')).toBe(`${SITE_ORIGIN}/grants`); + }); + + it('appends the correct path for blog', () => { + expect(canonicalUrl('blog')).toBe(`${SITE_ORIGIN}/blog`); + }); + + it('appends the correct path for caseStudies', () => { + expect(canonicalUrl('caseStudies')).toBe(`${SITE_ORIGIN}/case-studies`); + }); +}); + +describe('hreflangAlternates', () => { + it('returns en, es, pt-BR, and x-default alternates', () => { + const alts = hreflangAlternates('stellar'); + const hreflangs = alts.map((a) => a.hrefLang); + expect(hreflangs).toContain('en'); + expect(hreflangs).toContain('es'); + expect(hreflangs).toContain('pt-BR'); + expect(hreflangs).toContain('x-default'); + }); + + it('en and x-default alternates point to the canonical English URL', () => { + const canonical = canonicalUrl('grants'); + const alts = hreflangAlternates('grants'); + const enAlt = alts.find((a) => a.hrefLang === 'en'); + const xDefault = alts.find((a) => a.hrefLang === 'x-default'); + expect(enAlt?.href).toBe(canonical); + expect(xDefault?.href).toBe(canonical); + }); + + it('es alternate points to the /es-prefixed URL', () => { + const alts = hreflangAlternates('stellar'); + const esAlt = alts.find((a) => a.hrefLang === 'es'); + expect(esAlt?.href).toBe(`${SITE_ORIGIN}/es/stellar`); + }); + + it('pt-BR alternate points to the /pt-prefixed URL', () => { + const alts = hreflangAlternates('grants'); + const ptAlt = alts.find((a) => a.hrefLang === 'pt-BR'); + expect(ptAlt?.href).toBe(`${SITE_ORIGIN}/pt/grants`); + }); + + it('home page locale alternates use prefix-only paths', () => { + const alts = hreflangAlternates('home'); + const esAlt = alts.find((a) => a.hrefLang === 'es'); + const ptAlt = alts.find((a) => a.hrefLang === 'pt-BR'); + expect(esAlt?.href).toBe(`${SITE_ORIGIN}/es`); + expect(ptAlt?.href).toBe(`${SITE_ORIGIN}/pt`); + }); + + it('all hreflang URLs are distinct from each other', () => { + const alts = hreflangAlternates('blog'); + const urls = alts.map((a) => a.href); + // en and x-default intentionally share the canonical URL, so we expect + // at most one duplicate (those two). Every other URL must be unique. + const uniqueUrls = new Set(urls); + expect(uniqueUrls.size).toBeGreaterThanOrEqual(alts.length - 1); + }); + + it('does not duplicate hreflang values', () => { + const alts = hreflangAlternates('blog'); + const hreflangs = alts.map((a) => a.hrefLang); + expect(new Set(hreflangs).size).toBe(hreflangs.length); + }); +}); + +// ─── Page-level locale metadata tests ──────────────────────────────────────── + +/** + * Reads the document <title> as set by react-helmet-async. + * In jsdom, Helmet writes directly to document.title. + */ +function getDocumentTitle(): string { + return document.title; +} + +describe('locale-aware page metadata', () => { + beforeEach(async () => { + // Reset to English before each test + await i18n.changeLanguage('en'); + }); + + // ── Stellar ──────────────────────────────────────────────────────────────── + + it('Stellar page emits English title by default', async () => { + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Stellar page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/es/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /integrac/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + // Spanish translation contains "Stellar" and "Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + // Must NOT be the English default string + expect(getDocumentTitle()).not.toBe('Stealth payments on Stellar – Wraith Protocol'); + }); + + it('Stellar page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/pt/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + expect(getDocumentTitle()).not.toBe('Stealth payments on Stellar – Wraith Protocol'); + }); + + // ── Grants ───────────────────────────────────────────────────────────────── + + it('Grants page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + expect(getDocumentTitle()).toMatch(/Grants/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Grants page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/es/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /construye pagos privados/i }); + // Spanish: "Subvenciones – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Subvenciones/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Grants page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/pt/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /construa pagamentos privados/i }); + // Portuguese: "Bolsas – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Bolsas/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + // ── Blog ─────────────────────────────────────────────────────────────────── + + it('Blog page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/blog'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /wraith protocol blog/i }); + expect(getDocumentTitle()).toMatch(/Blog/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Blog page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/es/blog'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /wraith protocol blog/i }); + expect(getDocumentTitle()).toMatch(/Blog/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + expect(getDocumentTitle()).not.toBe( + 'Updates from Wraith Protocol on privacy-preserving payments and stealth infrastructure.', + ); + }); + + // ── CaseStudies ──────────────────────────────────────────────────────────── + + it('Case Studies page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /real-world privacy solutions/i, + }); + expect(getDocumentTitle()).toMatch(/Case Studies/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Case Studies page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/es/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /soluciones de privacidad en el mundo real/i, + }); + // Spanish: "Casos de estudio – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Casos de estudio/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Case Studies page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/pt/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /soluções de privacidade do mundo real/i, + }); + // Portuguese: "Estudos de caso – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Estudos de caso/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + // ── Home ─────────────────────────────────────────────────────────────────── + + it('Home page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /private payments/i }); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Home page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/es'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /pagos privados/i }); + // Spanish: "Wraith Protocol — Pagos privados para cada cadena" + expect(getDocumentTitle()).toMatch(/Pagos privados/i); + }); + + it('Home page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + // Use locale-prefixed route to avoid LocaleSync resetting to English + window.history.replaceState({}, '', '/pt'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /pagamentos privados/i }); + // Portuguese: "Wraith Protocol — Pagamentos privados para cada rede" + expect(getDocumentTitle()).toMatch(/Pagamentos privados/i); + }); + + // ── hreflang in rendered HTML ────────────────────────────────────────────── + + it('renders hreflang link tags for each supported locale on Stellar', async () => { + await i18n.changeLanguage('en'); + // Use locale-prefixed route to test hreflang generation + window.history.replaceState({}, '', '/es/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /integrac/i }); + + await waitFor(() => { + const links = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]')); + const hreflangs = links.map((l) => l.getAttribute('hreflang')); + expect(hreflangs).toContain('en'); + expect(hreflangs).toContain('es'); + expect(hreflangs).toContain('pt-BR'); + expect(hreflangs).toContain('x-default'); + }); + }); + + it('all hreflang links on Stellar point to the canonical URL', async () => { + await i18n.changeLanguage('en'); + // Use locale-prefixed route to test hreflang generation + window.history.replaceState({}, '', '/es/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /integrac/i }); + + await waitFor(() => { + const links = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]')); + const enLink = links.find((l) => l.getAttribute('hreflang') === 'en'); + const esLink = links.find((l) => l.getAttribute('hreflang') === 'es'); + const ptLink = links.find((l) => l.getAttribute('hreflang') === 'pt-BR'); + const xDefault = links.find((l) => l.getAttribute('hreflang') === 'x-default'); + + // English and x-default → canonical (non-prefixed) + expect(enLink?.getAttribute('href')).toBe(`${SITE_ORIGIN}/stellar`); + expect(xDefault?.getAttribute('href')).toBe(`${SITE_ORIGIN}/stellar`); + // Non-English → locale-prefixed distinct URLs + expect(esLink?.getAttribute('href')).toBe(`${SITE_ORIGIN}/es/stellar`); + expect(ptLink?.getAttribute('href')).toBe(`${SITE_ORIGIN}/pt/stellar`); + }); + }); + + it('renders a canonical link tag on the Grants page', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + + await waitFor(() => { + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical).not.toBeNull(); + expect(canonical?.getAttribute('href')).toBe(`${SITE_ORIGIN}/grants`); + }); + }); +}); diff --git a/src/components/CaseStudiesStrip.tsx b/src/components/CaseStudiesStrip.tsx index 26f8d95..756c54b 100644 --- a/src/components/CaseStudiesStrip.tsx +++ b/src/components/CaseStudiesStrip.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useLocalePath } from '../hooks/useLocalePath'; import { entries } from '../data/case-studies.json'; type CaseStudy = { @@ -18,6 +19,7 @@ type CaseStudy = { export default function CaseStudiesStrip() { const { t } = useTranslation(); + const lp = useLocalePath(); if (!entries || entries.length === 0) return null; @@ -40,7 +42,7 @@ export default function CaseStudiesStrip() { </p> </div> <Link - to="/case-studies" + to={lp('/case-studies')} 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('caseStudiesStrip.viewAll')} @@ -58,7 +60,7 @@ export default function CaseStudiesStrip() { return ( <Link key={study.id} - to={`/case-studies/${study.slug}`} + to={lp(`/case-studies/${study.slug}`)} className="group flex flex-col gap-5 border border-outline-variant bg-surface-container p-7 transition-colors duration-150 hover:bg-surface-bright" > <div className="flex items-start justify-between"> diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index fd93e22..1eb4bd9 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { trackOutbound } from '../utils/track'; +import { useLocalizedPath } from '../hooks/useLocalePath'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -40,6 +41,7 @@ type WidgetState = 'idle' | 'submitting' | 'success' | 'error'; function NewsletterWidget() { const { t } = useTranslation(); + const lp = useLocalizedPath(); const [email, setEmail] = useState(''); const [state, setState] = useState<WidgetState>('idle'); const inputRef = useRef<HTMLInputElement>(null); @@ -138,7 +140,7 @@ function NewsletterWidget() { )} <Link - to="/newsletter" + to={lp('/newsletter')} className="font-body text-[11px] text-outline transition-colors hover:text-on-surface-variant" > {t('newsletter.footerWidget.label').toLowerCase()} → @@ -151,6 +153,7 @@ function NewsletterWidget() { export default function Footer() { const { t } = useTranslation(); + const lp = useLocalizedPath(); const [status, setStatus] = useState<StatusState>({ label: 'Checking status...', tone: 'neutral', @@ -238,12 +241,13 @@ export default function Footer() { { label: t('footer.resources.erc6538'), href: 'https://eips.ethereum.org/EIPS/eip-6538' }, { label: t('footer.resources.security'), href: 'https://docs.usewraith.xyz/security' }, { label: t('footer.resources.press'), href: '/press' }, - { label: 'Blog', href: '/blog' }, - { label: 'Stellar Integration', href: '/stellar' }, - { label: 'Careers', href: '/careers' }, - { label: 'About', href: '/about' }, - { label: 'Governance', href: '/governance' }, - { label: 'Web Vitals', href: '/vitals' }, + { label: t('footer.resources.blog'), href: '/blog' }, + { label: t('footer.resources.caseStudies'), href: '/case-studies' }, + { label: t('footer.resources.stellar'), href: '/stellar' }, + { label: t('footer.resources.careers'), href: '/careers' }, + { label: t('footer.resources.about'), href: '/about' }, + { label: t('footer.resources.governance'), href: '/governance' }, + { label: t('footer.resources.vitals'), href: '/vitals' }, ], }, { @@ -285,7 +289,7 @@ export default function Footer() { return ( <Link key={link.label} - to={link.href} + to={lp(link.href)} className="font-body text-[13px] text-on-surface-variant transition-colors duration-150 hover:text-on-surface" > {link.label} @@ -368,7 +372,7 @@ export default function Footer() { <span>{status.label}</span> </a> <Link - to="/privacy" + to={lp('/privacy')} className="font-body text-xs text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('footer.legal.privacy')} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 59bb918..2fe5bcd 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useTheme } from '../context/ThemeContext'; import { changeLocale, Locale } from '../i18n'; +import { useLocalePath } from '../hooks/useLocalePath'; import { trackOutbound } from '../utils/track'; export default function Header() { @@ -11,6 +12,7 @@ export default function Header() { const { theme, toggleTheme } = useTheme(); const menuButtonRef = useRef<HTMLButtonElement>(null); const menuRef = useRef<HTMLDivElement>(null); + const lp = useLocalePath(); const currentLocale = (i18n.language.split('-')[0] ?? 'en') as Locale; const nextLocale: Locale = currentLocale === 'en' ? 'es' : 'en'; @@ -103,38 +105,38 @@ export default function Header() { > {t('header.nav.console')} </a> - <a - href="/press" + <Link + to={lp('/press')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.press')} - </a> - <a - href="/use-cases" + </Link> + <Link + to={lp('/use-cases')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.useCases')} - </a> + </Link> <Link - to="/grants" + to={lp('/grants')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.grants')} </Link> <Link - to="/stellar" + to={lp('/stellar')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > Stellar </Link> <Link - to="/roadmap" + to={lp('/roadmap')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.roadmap')} </Link> <Link - to="/blog" + to={lp('/blog')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.blog')} @@ -266,43 +268,43 @@ export default function Header() { > {t('header.nav.console')} </a> - <a - href="/press" + <Link + to={lp('/press')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.press')} - </a> - <a - href="/use-cases" + </Link> + <Link + to={lp('/use-cases')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.useCases')} - </a> + </Link> <Link - to="/grants" + to={lp('/grants')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.grants')} </Link> <Link - to="/stellar" + to={lp('/stellar')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > Stellar </Link> <Link - to="/roadmap" + to={lp('/roadmap')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.roadmap')} </Link> <Link - to="/blog" + to={lp('/blog')} onClick={closeMenu} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > diff --git a/src/hooks/useLocalePath.ts b/src/hooks/useLocalePath.ts new file mode 100644 index 0000000..54d9983 --- /dev/null +++ b/src/hooks/useLocalePath.ts @@ -0,0 +1,90 @@ +/** + * useLocalePath + * + * Returns a helper that prefixes internal paths with the active locale + * for non-English locales, and leaves them unprefixed for English. + * + * Usage: + * const lp = useLocalePath(); + * <Link to={lp('/grants')}>Grants</Link> + * // → '/es/grants' when locale is 'es' + * // → '/pt/grants' when locale is 'pt' + * // → '/grants' when locale is 'en' + */ + +import { useTranslation } from 'react-i18next'; +import { type Locale } from '../i18n'; + +const LOCALE_PREFIX: Partial<Record<Locale, string>> = { + es: '/es', + pt: '/pt', +}; + +// Routes that have localized versions (exist under /es/* and /pt/*) +// Base paths that support localization +const LOCALIZED_BASE_PATHS = new Set<string>([ + '/', + '/stellar', + '/grants', + '/blog', + '/case-studies', +]); + +/** + * Checks if a given path has a localized version. + * Only routes defined in the locale-prefixed routes in App.tsx should return true. + * This includes base paths and their sub-routes (e.g., /blog, /blog/:slug, /blog/author/:id). + */ +export function hasLocalizedRoute(path: string): boolean { + if (!path.startsWith('/')) return false; + if (path === '/') return true; + + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) return false; + + const basePath = `/${segments[0]}`; + + // Check if the base path is in the localized routes + if (LOCALIZED_BASE_PATHS.has(basePath)) { + return true; + } + + return false; +} + +export function useLocalePath(): (path: string) => string { + const { i18n } = useTranslation(); + const locale = ((i18n.language ?? 'en').split('-')[0] ?? 'en') as Locale; + const prefix = LOCALE_PREFIX[locale] ?? ''; + + return (path: string) => { + // External URLs, hash-only links, or already-prefixed paths pass through. + if (!path.startsWith('/') || path.startsWith(`/es`) || path.startsWith(`/pt`)) { + return path; + } + return `${prefix}${path}`; + }; +} + +/** + * useLocalizedPath + * + * Like useLocalePath, but only prefixes routes that have localized versions. + * Routes without localized versions (e.g., /privacy, /about, /vitals) are + * returned unprefixed even for non-English locales. + */ +export function useLocalizedPath(): (path: string) => string { + const { i18n } = useTranslation(); + const locale = ((i18n.language ?? 'en').split('-')[0] ?? 'en') as Locale; + const prefix = LOCALE_PREFIX[locale] ?? ''; + + return (path: string) => { + if (!path.startsWith('/') || path.startsWith(`/es`) || path.startsWith(`/pt`)) { + return path; + } + if (!hasLocalizedRoute(path)) { + return path; + } + return `${prefix}${path}`; + }; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index d723346..d20d389 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -302,7 +302,14 @@ "erc5564": "ERC-5564 spec", "erc6538": "ERC-6538 spec", "security": "Security", - "press": "Press" + "press": "Press", + "blog": "Blog", + "caseStudies": "Case Studies", + "stellar": "Stellar Integration", + "careers": "Careers", + "about": "About", + "governance": "Governance", + "vitals": "Web Vitals" }, "acknowledgmentsAria": "Supported by Stellar Development Foundation’s Stellar Wave program via Drips", "acknowledgmentsText": "Supported by Stellar Development Foundation’s Stellar Wave program via Drips", @@ -319,6 +326,28 @@ "securityTxt": "Security.txt" } }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Private payments for every chain", + "description": "Wraith Protocol enables private payments on any blockchain using stealth addresses. One SDK, multiple chains." + }, + "stellar": { + "title": "Stellar Integration — Wraith Protocol", + "description": "Native stealth address support on the Stellar network with memo-based metadata." + }, + "grants": { + "title": "Grants — Wraith Protocol", + "description": "Wraith Protocol runs a grant program for privacy infrastructure. Build private payments and get funded." + }, + "blog": { + "title": "Blog — Wraith Protocol", + "description": "Updates, guides, and deep dives from the Wraith team." + }, + "caseStudies": { + "title": "Case Studies — Wraith Protocol", + "description": "Real-world privacy solutions built on Wraith Protocol. From payroll processors to DAOs." + } + }, "og": { "siteTitle": "Wraith Protocol — Private payments for every chain", "metaDescription": "Wraith Protocol enables private payments on any blockchain using stealth addresses. One SDK, multiple chains.", diff --git a/src/i18n/es.json b/src/i18n/es.json index 7c57269..039ba2e 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -302,7 +302,14 @@ "erc5564": "Especificación ERC-5564", "erc6538": "Especificación ERC-6538", "security": "Seguridad", - "press": "Prensa" + "press": "Prensa", + "blog": "Blog", + "caseStudies": "Casos de Estudio", + "stellar": "Integración Stellar", + "careers": "Empleos", + "about": "Acerca de", + "governance": "Gobernanza", + "vitals": "Web Vitals" }, "acknowledgmentsAria": "Con el apoyo de la Stellar Development Foundation a través del programa Stellar Wave vía Drips", "acknowledgmentsText": "Con el apoyo de la Stellar Development Foundation a través del programa Stellar Wave vía Drips", @@ -319,6 +326,28 @@ "securityTxt": "Security.txt" } }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Pagos privados para cada cadena", + "description": "Wraith Protocol habilita pagos privados en cualquier blockchain usando direcciones ocultas. Un SDK, múltiples cadenas." + }, + "stellar": { + "title": "Integración Stellar — Wraith Protocol", + "description": "Soporte nativo de direcciones ocultas en la red Stellar con metadatos basados en memo." + }, + "grants": { + "title": "Subvenciones — Wraith Protocol", + "description": "Wraith Protocol mantiene un programa de subvenciones para infraestructura de privacidad. Construye pagos privados y obtén financiación." + }, + "blog": { + "title": "Blog — Wraith Protocol", + "description": "Actualizaciones, guías y análisis a fondo del equipo de Wraith." + }, + "caseStudies": { + "title": "Casos de Estudio — Wraith Protocol", + "description": "Soluciones de privacidad reales construidas sobre Wraith Protocol. Desde procesadores de nómina hasta DAOs." + } + }, "og": { "siteTitle": "Wraith Protocol — Pagos privados para cada cadena", "metaDescription": "Wraith Protocol habilita pagos privados en cualquier blockchain usando direcciones ocultas. Un SDK, múltiples cadenas.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index f719906..4490e47 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -302,7 +302,14 @@ "erc5564": "Especificação ERC-5564", "erc6538": "Especificação ERC-6538", "security": "Segurança", - "press": "Imprensa" + "press": "Imprensa", + "blog": "Blog", + "caseStudies": "Estudos de Caso", + "stellar": "Integração Stellar", + "careers": "Carreiras", + "about": "Sobre", + "governance": "Governança", + "vitals": "Web Vitals" }, "acknowledgmentsAria": "Com apoio da Stellar Development Foundation através do programa Stellar Wave via Drips", "acknowledgmentsText": "Com apoio da Stellar Development Foundation através do programa Stellar Wave via Drips", @@ -319,6 +326,28 @@ "securityTxt": "Security.txt" } }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Pagamentos privados para cada rede", + "description": "Wraith Protocol permite pagamentos privados em qualquer blockchain usando endereços ocultos. Um SDK, múltiplas redes." + }, + "stellar": { + "title": "Integração Stellar — Wraith Protocol", + "description": "Suporte nativo a endereços ocultos na rede Stellar com metadados baseados em memo." + }, + "grants": { + "title": "Bolsas — Wraith Protocol", + "description": "O Wraith Protocol mantém um programa de bolsas para infraestrutura de privacidade. Construa pagamentos privados e receba financiamento." + }, + "blog": { + "title": "Blog — Wraith Protocol", + "description": "Atualizações, guias e análises aprofundadas da equipe Wraith." + }, + "caseStudies": { + "title": "Casos de Estudo — Wraith Protocol", + "description": "Soluções de privacidade reais construídas sobre o Wraith Protocol. De processadores de folha de pagamento a DAOs." + } + }, "og": { "siteTitle": "Wraith Protocol — Pagamentos privados para cada rede", "metaDescription": "Wraith Protocol permite pagamentos privados em qualquer blockchain usando endereços ocultos. Um SDK, múltiplas redes.", diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index ca35e08..8d4c25b 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { getAllPosts, getPostBySlug, @@ -16,9 +17,11 @@ import { import BlogToc from '../components/BlogToc'; import { article, breadcrumbList, serializeJsonLd, SITE_URL } from '../utils/jsonld'; import { track } from '../utils/track'; -import i18n from '../i18n'; +import { usePageSeo } from '../utils/seo'; +import { useLocalizedPath } from '../hooks/useLocalePath'; +import { localizedDynamicUrl, hreflangAlternatesDynamic } from '../utils/seo'; -function AuthorByline({ post }: { post: BlogPost }) { +function AuthorByline({ post, lp }: { post: BlogPost; lp: (path: string) => string }) { if (!post.author) return null; return ( @@ -26,7 +29,7 @@ function AuthorByline({ post }: { post: BlogPost }) { <span>•</span> {post.authorId ? ( <Link - to={`/blog/author/${post.authorId}`} + to={lp(`/blog/author/${post.authorId}`)} className="transition-colors hover:text-on-surface" > {post.authorName} @@ -46,6 +49,8 @@ function normalizeLocale(lang: string | undefined): string { function BlogList() { const posts = getAllPosts(); + const seo = usePageSeo('blog'); + const lp = useLocalizedPath(); const listCrumbs = breadcrumbList([ { name: 'Home', url: SITE_URL }, @@ -59,11 +64,15 @@ function BlogList() { dangerouslySetInnerHTML={{ __html: serializeJsonLd(listCrumbs) }} /> <Helmet> - <title>Blog – Wraith Protocol - + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + +
- + • {post.readingTimeMin} min read

- {post.title} + {post.title}

{post.excerpt && (

@@ -126,6 +135,9 @@ function BlogPostDetail({ slug }: { slug: string }) { // One-shot guard: ensures a single `blog_post_read` per article page view, // independent of re-renders or continued scrolling. const readFiredRef = useRef(false); + const lp = useLocalizedPath(); + const { i18n: i18nInstance } = useTranslation(); + const locale = (i18nInstance.language?.split('-')[0] ?? 'en') as 'en' | 'es' | 'pt'; useEffect(() => { if (!post) return; @@ -138,7 +150,7 @@ function BlogPostDetail({ slug }: { slug: string }) { readFiredRef.current = true; track('blog_post_read', { slug: post.slug, - locale: normalizeLocale(i18n.language), + locale: normalizeLocale(i18nInstance.language), }); window.removeEventListener('scroll', onScroll); }; @@ -166,7 +178,7 @@ function BlogPostDetail({ slug }: { slug: string }) { onScroll(); return () => window.removeEventListener('scroll', onScroll); - }, [post, slug]); + }, [post, slug, i18nInstance.language]); if (!post) { return ( @@ -176,7 +188,7 @@ function BlogPostDetail({ slug }: { slug: string }) {

Post Not Found

The requested blog post could not be found.

- + ← Back to Blog
@@ -185,7 +197,8 @@ function BlogPostDetail({ slug }: { slug: string }) { const { Component } = post; - const postUrl = `${SITE_URL}/blog/${post.slug}`; + const dynamicPath = `/${post.slug}`; + const postUrl = localizedDynamicUrl('blog', locale, dynamicPath); const postArticle = article({ headline: post.title, description: post.excerpt, @@ -195,9 +208,10 @@ function BlogPostDetail({ slug }: { slug: string }) { }); const postCrumbs = breadcrumbList([ { name: 'Home', url: SITE_URL }, - { name: 'Blog', url: `${SITE_URL}/blog` }, + { name: 'Blog', url: localizedDynamicUrl('blog', locale, '') }, { name: post.title, url: postUrl }, ]); + const alternates = hreflangAlternatesDynamic('blog', dynamicPath); return (
@@ -212,6 +226,10 @@ function BlogPostDetail({ slug }: { slug: string }) { {post.title} – Wraith Protocol {post.excerpt && } + + {alternates.map((alt) => ( + + ))} {post.tags.map((tag) => ( ← Back to Blog
- + • {post.readingTimeMin} min read
@@ -283,6 +301,9 @@ function AuthorInitials({ name }: { name: string }) { function BlogAuthor({ id }: { id: string }) { const author = getAuthorById(id); + const lp = useLocalizedPath(); + const { i18n: i18nInstance } = useTranslation(); + const locale = (i18nInstance.language?.split('-')[0] ?? 'en') as 'en' | 'es' | 'pt'; if (!author) { return ( @@ -293,9 +314,9 @@ function BlogAuthor({ id }: { id: string }) {

Author Not Found

- We couldn't find a public author page for “{id}”. + We couldn't find a public author page for "{id}".

- + ← Back to Blog @@ -309,6 +330,10 @@ function BlogAuthor({ id }: { id: string }) { string, ][]; + const dynamicPath = `/author/${id}`; + const authorUrl = localizedDynamicUrl('blog', locale, dynamicPath); + const alternates = hreflangAlternatesDynamic('blog', dynamicPath); + return (