diff --git a/e2e/analytics.spec.ts b/e2e/analytics.spec.ts new file mode 100644 index 0000000..4f28be1 --- /dev/null +++ b/e2e/analytics.spec.ts @@ -0,0 +1,193 @@ +/** + * Acceptance tests for the first-party analytics instrumentation. + * + * The real Plausible CDN script is blocked and replaced with a deterministic + * local probe so event requests can be asserted at the network boundary. + */ + +import { test, expect, type Page, type Route } from '@playwright/test'; + +const ANALYTICS_ENDPOINT = '**/api/event**'; +const PLAUSIBLE_SCRIPT = '**/plausible.io/js/**'; + +type PlausibleEvent = { name: string; props?: Record }; + +async function setupAnalyticsProbe(page: Page): Promise { + const events: PlausibleEvent[] = []; + + await page.route(PLAUSIBLE_SCRIPT, (route: Route) => route.abort()); + await page.route(ANALYTICS_ENDPOINT, (route: Route) => { + const body = route.request().postData(); + if (body) { + try { + events.push(JSON.parse(body) as PlausibleEvent); + } catch { + // Ignore non-JSON payloads from unrelated requests. + } + } + return route.fulfill({ status: 204 }); + }); + + await page.addInitScript(() => { + (window as unknown as { plausible: unknown }).plausible = function ( + name: string, + opts?: { props?: Record }, + ) { + void fetch('https://plausible.io/api/event', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: JSON.stringify({ + name, + domain: location.hostname, + props: opts?.props ?? {}, + }), + keepalive: true, + }); + }; + }); + + return events; +} + +async function enableDNT(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'doNotTrack', { + configurable: true, + get: () => '1', + }); + }); +} + +test.describe('first-party analytics', () => { + test('CTA click emits exactly one cta_click', async ({ page }) => { + const events = await setupAnalyticsProbe(page); + await page.route('**/console.usewraith.xyz/**', (route: Route) => route.abort()); + await page.goto('/'); + + const ctaStrip = page.locator('section', { hasText: 'Start shipping private payments' }); + const cta = ctaStrip.getByRole('link', { name: /get api keys/i }).first(); + await cta.waitFor(); + await cta.click(); + await expect.poll(() => events.filter((event) => event.name === 'cta_click').length).toBe(1); + + const ctaEvents = events.filter((event) => event.name === 'cta_click'); + expect(ctaEvents[0]?.props?.source).toBe('ctastrip-console'); + }); + + test('newsletter success emits exactly one newsletter_submit and no confirm', async ({ + page, + }) => { + const events = await setupAnalyticsProbe(page); + await page.route('**/api/subscribe', (route: Route) => + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }), + ); + await page.goto('/newsletter'); + + await page.locator('#newsletter-email').fill('reader@example.com'); + const submit = page.getByRole('main').getByRole('button', { name: /subscribe/i }); + await submit.click(); + await expect + .poll(() => events.filter((event) => event.name === 'newsletter_submit').length) + .toBe(1); + + await expect(submit).toBeHidden(); + await page.waitForTimeout(100); + + expect(events.filter((event) => event.name === 'newsletter_submit')).toHaveLength(1); + expect(events.filter((event) => event.name === 'newsletter_confirm')).toHaveLength(0); + }); + + test('blog_post_read fires once at/after 80% scroll', async ({ page }) => { + const events = await setupAnalyticsProbe(page); + await page.goto('/blog/wave-7-kickoff'); + await page.getByRole('heading', { level: 1 }).first().waitFor(); + + await page.evaluate(() => + window.scrollTo({ top: document.documentElement.scrollHeight * 0.4, behavior: 'instant' }), + ); + await page.waitForTimeout(100); + expect(events.filter((event) => event.name === 'blog_post_read')).toHaveLength(0); + + await page.evaluate(() => + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }), + ); + await expect + .poll(() => events.filter((event) => event.name === 'blog_post_read').length) + .toBe(1); + + await page.evaluate(() => + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }), + ); + await page.waitForTimeout(100); + expect(events.filter((event) => event.name === 'blog_post_read')).toHaveLength(1); + }); + + test('calculator share emits exactly one calculator_share after a successful copy', async ({ + page, + }) => { + const events = await setupAnalyticsProbe(page); + await page.goto('/use-cases/calculator'); + + await page.getByRole('button', { name: /copy scenario link/i }).click(); + await expect(page.getByText(/scenario link copied/i)).toBeVisible(); + await expect + .poll(() => events.filter((event) => event.name === 'calculator_share').length) + .toBe(1); + + const shares = events.filter((event) => event.name === 'calculator_share'); + expect(shares[0]?.props?.source).toBe('cost-calculator'); + }); + + test('outbound click emits exactly one outbound_click with category', async ({ page }) => { + const events = await setupAnalyticsProbe(page); + await page.goto('/'); + + const github = page.getByRole('link', { name: /github/i }).first(); + await github.click(); + await expect + .poll(() => events.filter((event) => event.name === 'outbound_click').length) + .toBe(1); + + const outbound = events.filter((event) => event.name === 'outbound_click'); + expect(outbound[0]?.props?.category).toBe('github'); + }); + + test('DNT enabled => zero Plausible script and analytics requests', async ({ page }) => { + await enableDNT(page); + const analyticsRequests: string[] = []; + const scriptRequests: string[] = []; + + page.on('request', (request) => { + const url = request.url(); + if (url.includes('plausible.io/js/')) scriptRequests.push(url); + if (url.includes('/api/event')) analyticsRequests.push(url); + }); + + await page.goto('/'); + await page + .getByRole('link', { name: /github/i }) + .first() + .click() + .catch(() => {}); + await page.goto('/newsletter'); + await page.locator('#newsletter-email').fill('reader@example.com'); + await page + .getByRole('main') + .getByRole('button', { name: /subscribe/i }) + .click() + .catch(() => {}); + await page.goto('/use-cases/calculator'); + await page + .getByRole('button', { name: /copy scenario link/i }) + .click() + .catch(() => {}); + await page.waitForTimeout(200); + + expect(scriptRequests).toHaveLength(0); + expect(analyticsRequests).toHaveLength(0); + }); +}); diff --git a/index.html b/index.html index e50fa3d..b2847fe 100644 --- a/index.html +++ b/index.html @@ -32,23 +32,30 @@ - https://usewraith.xyz/blog Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Tue, 25 Aug 2026 22:23:09 GMT + Sat, 29 Aug 2026 13:32:10 GMT + + How Stealth Addresses Keep Payments Private + https://usewraith.xyz/blog/stealth-addresses-explained + https://usewraith.xyz/blog/stealth-addresses-explained + Wed, 12 Aug 2026 00:00:00 GMT + A look at the cryptography behind stealth addresses and why every Wraith payment lands on a fresh one-time address that cannot be linked to the recipient. + Lena Vogt + Wave 7 Kick-off + What We Shipped in Wave 6 https://usewraith.xyz/blog/wave-7-kickoff https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team - - - Stealth addresses explained - https://usewraith.xyz/blog/stealth-addresses-explained - https://usewraith.xyz/blog/stealth-addresses-explained - Wed, 22 Jul 2026 12:00:00 GMT - A straightforward introduction to stealth addresses and why they matter. Wraith Team Privacy by default https://usewraith.xyz/blog/privacy-by-default https://usewraith.xyz/blog/privacy-by-default - Mon, 20 Jul 2026 12:00:00 GMT + Mon, 20 Jul 2026 00:00:00 GMT How Wraith makes private payments practical for everyday apps. - Wraith Team + Wraith Protocol Team \ No newline at end of file diff --git a/public/feed/tag/announcements.xml b/public/feed/tag/announcements.xml index 4d827f4..5c59a45 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 - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT @@ -14,7 +14,7 @@ https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team + Wraith Team \ No newline at end of file diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml index f5f204d..e9d4598 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 - Thu, 27 Aug 2026 12:50:19 GMT + Sat, 29 Aug 2026 13:32:10 GMT diff --git a/public/feed/tag/privacy.xml b/public/feed/tag/privacy.xml index 0505557..226dfdb 100644 --- a/public/feed/tag/privacy.xml +++ b/public/feed/tag/privacy.xml @@ -5,16 +5,16 @@ https://usewraith.xyz/blog/tag/privacy Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT - Stealth addresses explained + How Stealth Addresses Keep Payments Private https://usewraith.xyz/blog/stealth-addresses-explained https://usewraith.xyz/blog/stealth-addresses-explained - Wed, 22 Jul 2026 00:00:00 GMT - A straightforward introduction to stealth addresses and why they matter for private payments. - Wraith Protocol Team + Wed, 12 Aug 2026 00:00:00 GMT + A look at the cryptography behind stealth addresses and why every Wraith payment lands on a fresh one-time address that cannot be linked to the recipient. + Lena Vogt Privacy by default diff --git a/public/feed/tag/sdk.xml b/public/feed/tag/sdk.xml index 6e2468b..b7e2ac5 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 - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT @@ -14,15 +14,7 @@ https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team - - - Stealth addresses explained - https://usewraith.xyz/blog/stealth-addresses-explained - https://usewraith.xyz/blog/stealth-addresses-explained - Wed, 22 Jul 2026 00:00:00 GMT - A straightforward introduction to stealth addresses and why they matter for private payments. - Wraith Protocol Team + Wraith Team \ No newline at end of file diff --git a/public/feed/tag/stealth-payments.xml b/public/feed/tag/stealth-payments.xml index 9b90c50..b6a3ab6 100644 --- a/public/feed/tag/stealth-payments.xml +++ b/public/feed/tag/stealth-payments.xml @@ -5,24 +5,24 @@ https://usewraith.xyz/blog/tag/stealth-payments Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT + + How Stealth Addresses Keep Payments Private + https://usewraith.xyz/blog/stealth-addresses-explained + https://usewraith.xyz/blog/stealth-addresses-explained + Wed, 12 Aug 2026 00:00:00 GMT + A look at the cryptography behind stealth addresses and why every Wraith payment lands on a fresh one-time address that cannot be linked to the recipient. + Lena Vogt + Wave 7 Kick-off + What We Shipped in Wave 6 https://usewraith.xyz/blog/wave-7-kickoff https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team - - - Stealth addresses explained - https://usewraith.xyz/blog/stealth-addresses-explained - https://usewraith.xyz/blog/stealth-addresses-explained - Wed, 22 Jul 2026 00:00:00 GMT - A straightforward introduction to stealth addresses and why they matter for private payments. - Wraith Protocol Team + Wraith Team Privacy by default diff --git a/public/feed/tag/wave-6.xml b/public/feed/tag/wave-6.xml index 9a8b4b3..cbdd354 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 - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT @@ -14,7 +14,7 @@ https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team + Wraith Team \ No newline at end of file diff --git a/public/feed/tag/wave-7.xml b/public/feed/tag/wave-7.xml index 08ad5bd..e99d6fc 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 - Tue, 25 Aug 2026 05:06:07 GMT + Sat, 29 Aug 2026 13:32:10 GMT @@ -14,7 +14,7 @@ https://usewraith.xyz/blog/wave-7-kickoff Mon, 27 Jul 2026 00:00:00 GMT Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements. - Wraith Protocol Team + Wraith Team \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml index 1288f41..14eac0b 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,85 +2,133 @@ https://usewraith.xyz - 2026-08-25 + 2026-08-29 daily 1.0 https://usewraith.xyz/faq - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/privacy - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/use-cases - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/use-cases/calculator - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/roadmap - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/case-studies - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/stellar - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/careers - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/press - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/case-studies/payroll-processor - 2026-08-25 + 2026-08-29 weekly 0.7 + + https://usewraith.xyz/blog/author/lena-vogt + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/privacy + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/stealth-payments + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/cryptography + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/announcements + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/wave-7 + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/wave-6 + 2026-08-29 + weekly + 0.8 + + + https://usewraith.xyz/blog/tag/sdk + 2026-08-29 + weekly + 0.8 + https://usewraith.xyz/blog - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/compare - 2026-08-25 + 2026-08-29 weekly 0.8 https://usewraith.xyz/newsletter - 2026-08-25 + 2026-08-29 weekly 0.8 diff --git a/src/App.tsx b/src/App.tsx index 913cf51..840cb59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,7 @@ const Status = lazy(() => import('./pages/Status')); const NotFound = lazy(() => import('./pages/NotFound')); const Contributors = lazy(() => import('./pages/Contributors')); const Blog = lazy(() => import('./pages/Blog')); +const Ecosystem = lazy(() => import('./pages/Ecosystem')); function Home() { return ( @@ -89,6 +90,14 @@ export default function App() { } /> } /> } /> + + + + } + /> } /> { + delete window.plausible; await i18n.changeLanguage('en'); }); @@ -155,6 +156,8 @@ describe('CostCalculator', () => { }); it('copies a canonical standalone scenario URL and surfaces fallback failure', async () => { + const plausible = vi.fn(); + window.plausible = plausible; const writeText = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error()); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -173,6 +176,9 @@ describe('CostCalculator', () => { expect.stringContaining('/use-cases/calculator?chain=beta&payments=250&avg=80'), ); expect(await screen.findByText('Scenario link copied.')).toBeInTheDocument(); + expect(plausible).toHaveBeenCalledWith('calculator_share', { + props: { source: 'cost-calculator' }, + }); fireEvent.click(copy); expect( diff --git a/src/__tests__/StealthAnimation.test.tsx b/src/__tests__/StealthAnimation.test.tsx index 86a2cc1..15d300c 100644 --- a/src/__tests__/StealthAnimation.test.tsx +++ b/src/__tests__/StealthAnimation.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import StealthAnimation from '../components/StealthAnimation'; +import '../i18n'; describe('StealthAnimation', () => { it('renders the section with heading and description', () => { diff --git a/src/__tests__/careers.test.tsx b/src/__tests__/careers.test.tsx index d52df1e..e80c89b 100644 --- a/src/__tests__/careers.test.tsx +++ b/src/__tests__/careers.test.tsx @@ -45,7 +45,9 @@ describe('Careers page', () => { render(); - const emailInput = await screen.findByLabelText(/email address/i); + await screen.findByRole('heading', { level: 1, name: /not hiring right now/i }); + const emailInput = document.getElementById('careers-email') as HTMLInputElement; + expect(emailInput).toBeInstanceOf(HTMLInputElement); await user.type(emailInput, 'careers-test@example.com'); await user.click(screen.getByRole('button', { name: /stay in touch/i })); @@ -81,7 +83,9 @@ describe('Careers page', () => { render(); - const emailInput = await screen.findByLabelText(/email address/i); + await screen.findByRole('heading', { level: 1, name: /not hiring right now/i }); + const emailInput = document.getElementById('careers-email') as HTMLInputElement; + expect(emailInput).toBeInstanceOf(HTMLInputElement); await user.type(emailInput, 'bad@example.com'); await user.click(screen.getByRole('button', { name: /stay in touch/i })); diff --git a/src/__tests__/vitals.test.tsx b/src/__tests__/vitals.test.tsx index a4c085b..9417d4f 100644 --- a/src/__tests__/vitals.test.tsx +++ b/src/__tests__/vitals.test.tsx @@ -152,4 +152,50 @@ describe('Web Vitals Dashboard Page (/vitals)', () => { const results = await axe(container); expect(results.violations).toEqual([]); }); + + it('provides a locale filter and combines it with the page filter', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByRole('heading', { name: /web vitals dashboard/i, level: 1 }); + + const localeSelect = screen.getByLabelText(/locale:/i); + expect(localeSelect).toBeInTheDocument(); + await user.selectOptions(localeSelect, 'es'); + expect((localeSelect as HTMLSelectElement).value).toBe('es'); + + const pageSelect = screen.getByLabelText(/page:/i); + await user.selectOptions(pageSelect, '/faq'); + expect((pageSelect as HTMLSelectElement).value).toBe('/faq'); + }); + + it('renders conversion-event tiles (synthetic fixtures)', async () => { + render( + + + , + ); + + await screen.findByRole('heading', { name: /web vitals dashboard/i, level: 1 }); + + expect(screen.getByRole('heading', { name: /conversion events/i })).toBeInTheDocument(); + expect(screen.getByText(/cta clicks/i)).toBeInTheDocument(); + expect(screen.getByText(/newsletter signups/i)).toBeInTheDocument(); + }); + + it('renders the incident overlay empty state (no incident feed yet)', async () => { + render( + + + , + ); + + await screen.findByRole('heading', { name: /web vitals dashboard/i, level: 1 }); + + expect(screen.getByText(/no incidents reported in the last 30 days/i)).toBeInTheDocument(); + }); }); diff --git a/src/analytics.test.ts b/src/analytics.test.ts new file mode 100644 index 0000000..3f8f5fe --- /dev/null +++ b/src/analytics.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { trackEvent } from './analytics'; + +afterEach(() => { + vi.unstubAllGlobals(); + delete window.plausible; +}); + +describe('trackEvent', () => { + it('forwards events when the analytics script is loaded', () => { + window.plausible = vi.fn(); + + trackEvent('cta_click', { props: { source: 'test' } }); + + expect(window.plausible).toHaveBeenCalledWith('cta_click', { props: { source: 'test' } }); + }); + + it('does not throw before the analytics script loads', () => { + expect(() => trackEvent('cta_click')).not.toThrow(); + }); + + it('does not throw during server-side rendering', () => { + vi.stubGlobal('window', undefined); + + expect(() => trackEvent('cta_click')).not.toThrow(); + }); +}); diff --git a/src/analytics.ts b/src/analytics.ts index cff1aff..8cfb6eb 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -25,7 +25,7 @@ export function trackEvent( event: string, options?: { props?: Record }, ): void { - if (typeof window.plausible === 'function') { + if (typeof window !== 'undefined' && typeof window.plausible === 'function') { window.plausible(event, options); } } diff --git a/src/components/CostCalculator.tsx b/src/components/CostCalculator.tsx index 597c111..20c10f6 100644 --- a/src/components/CostCalculator.tsx +++ b/src/components/CostCalculator.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { CALCULATOR_CHAINS, type CalculatorChain } from '../data/calculatorChains'; import { copyToClipboard } from '../utils/clipboard'; +import { track } from '../utils/track'; export type CostChain = CalculatorChain; @@ -181,6 +182,7 @@ export default function CostCalculator({ chains = CALCULATOR_CHAINS }: Props) { try { await copyToClipboard(url); + track('calculator_share', { source: 'cost-calculator' }); setShareStatus('copied'); } catch { setShareStatus('failed'); diff --git a/src/components/CtaStrip.tsx b/src/components/CtaStrip.tsx index d2a5a25..7ac956d 100644 --- a/src/components/CtaStrip.tsx +++ b/src/components/CtaStrip.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { trackEvent } from '../analytics'; +import { track } from '../utils/track'; export default function CtaStrip() { const { t } = useTranslation(); @@ -19,7 +19,7 @@ export default function CtaStrip() { href="https://console.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Get API Key')} + onClick={() => track('cta_click', { source: 'ctastrip-console' })} className="flex h-12 items-center justify-center bg-primary px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-surface transition-[filter] duration-150 hover:brightness-110" > {t('cta.getKeys')} @@ -28,7 +28,7 @@ export default function CtaStrip() { href="https://docs.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Read the Docs')} + onClick={() => track('cta_click', { source: 'ctastrip-docs' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > {t('cta.readDocs')} diff --git a/src/components/EcosystemPartners.tsx b/src/components/EcosystemPartners.tsx index 83e4985..13f1a40 100644 --- a/src/components/EcosystemPartners.tsx +++ b/src/components/EcosystemPartners.tsx @@ -1,4 +1,5 @@ import { useInView } from '../hooks/useInView'; +import { trackOutbound } from '../utils/track'; const partners = [ { @@ -80,6 +81,7 @@ export default function EcosystemPartners() { key={partner.shortName} href={partner.link} target="_blank" + onClick={trackOutbound('ecosystem')} rel="noopener noreferrer" className="group flex flex-col gap-5 border border-outline-variant-30 bg-surface-container p-6 transition-all duration-200 hover:border-outline hover:bg-surface-bright rounded-none" data-reveal={isInView} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 125b2b9..fd93e22 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import { trackOutbound } from '../utils/track'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -297,6 +298,7 @@ export default function Footer() { href={link.href} target={isInternal ? undefined : '_blank'} rel={isInternal ? undefined : 'noopener noreferrer'} + onClick={isInternal ? undefined : trackOutbound('social')} className="font-body text-[13px] text-on-surface-variant transition-colors duration-150 hover:text-on-surface" > {link.label} @@ -314,6 +316,7 @@ export default function Footer() { href="https://www.drips.network/wave/stellar" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('other')} aria-label={t('footer.acknowledgmentsAria')} className="group flex flex-col gap-2 font-body text-[13px] leading-[1.45] text-on-surface-variant transition-colors duration-150 hover:text-on-surface" > @@ -346,6 +349,7 @@ export default function Footer() { href={statusPageUrl} target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('other')} aria-label="Open Wraith Protocol status page" className={`inline-flex items-center gap-2 rounded-none border px-2.5 py-1.5 font-body text-[11px] uppercase tracking-[1.5px] transition-colors duration-150 ${status.tone}`} > @@ -371,12 +375,14 @@ export default function Footer() { {t('footer.legal.terms')} {t('footer.legal.securityTxt')} diff --git a/src/components/ForDevelopers.tsx b/src/components/ForDevelopers.tsx index 5cdb2e4..cff3d7b 100644 --- a/src/components/ForDevelopers.tsx +++ b/src/components/ForDevelopers.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { useInView } from '../hooks/useInView'; +import { trackOutbound } from '../utils/track'; export default function ForDevelopers() { const { t } = useTranslation(); @@ -54,6 +55,7 @@ export default function ForDevelopers() { @@ -95,6 +97,7 @@ export default function ForDevelopers() { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8dd214a..59bb918 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 { trackOutbound } from '../utils/track'; export default function Header() { const { t, i18n } = useTranslation(); @@ -70,6 +71,7 @@ export default function Header() { href="https://docs.usewraith.xyz" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('docs')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.docs')} @@ -78,6 +80,7 @@ export default function Header() { href="https://docs.usewraith.xyz/sdk/overview" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('docs')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.sdk')} @@ -86,6 +89,7 @@ export default function Header() { href="https://demo.usewraith.xyz" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('other')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.demo')} @@ -94,6 +98,7 @@ export default function Header() { href="https://console.usewraith.xyz" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('other')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.nav.console')} @@ -160,6 +165,7 @@ export default function Header() { href="https://github.com/wraith-protocol" target="_blank" rel="noopener noreferrer" + onClick={trackOutbound('github')} className="font-body text-[13px] text-outline transition-colors duration-150 hover:text-on-surface-variant" > {t('header.github')} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 570dec9..f354877 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -2,6 +2,7 @@ import { useState, type KeyboardEvent } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { trackEvent } from '../analytics'; +import { track } from '../utils/track'; type CodeLine = { content: string; @@ -180,7 +181,7 @@ export default function Hero() { href="https://docs.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Read the Docs')} + onClick={() => track('cta_click', { source: 'hero-docs' })} className="flex h-12 items-center justify-center bg-primary px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-surface transition-[filter] duration-150 hover:brightness-110" > {t('hero.cta.docs')} @@ -189,7 +190,7 @@ export default function Hero() { href="https://demo.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Try the Demo')} + onClick={() => track('cta_click', { source: 'hero-demo' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > {t('hero.cta.demo')} diff --git a/src/components/PartnerStrip.tsx b/src/components/PartnerStrip.tsx index d7690e8..042b781 100644 --- a/src/components/PartnerStrip.tsx +++ b/src/components/PartnerStrip.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useInView } from '../hooks/useInView'; +import { trackOutbound } from '../utils/track'; import ecosystemData from '../data/ecosystem.json'; type Partner = (typeof ecosystemData.partners)[number]; @@ -31,6 +32,7 @@ function PartnerItem({ partner }: { partner: Partner }) { diff --git a/src/components/StealthAnimation.tsx b/src/components/StealthAnimation.tsx index 9b96b85..1e5f7f6 100644 --- a/src/components/StealthAnimation.tsx +++ b/src/components/StealthAnimation.tsx @@ -236,11 +236,11 @@ export default function StealthAnimation() {
  • - {t('stealthAnimation.benefits.unique')} + {t('stealthAnimation.benefits.oneTime')}
  • - {t('stealthAnimation.benefits.detectOnly')} + {t('stealthAnimation.benefits.passive')}
  • diff --git a/src/components/TrustStrip.tsx b/src/components/TrustStrip.tsx index 2e8f3a4..5f6bac7 100644 --- a/src/components/TrustStrip.tsx +++ b/src/components/TrustStrip.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useInView } from '../hooks/useInView'; +import { trackOutbound } from '../utils/track'; import trustData from '../data/trust.json'; type AuditStatus = 'verified' | 'scheduled'; @@ -82,6 +83,7 @@ export default function TrustStrip() { key={item.label} href={item.link} target="_blank" + onClick={trackOutbound('other')} rel="noopener noreferrer" className="group flex items-center gap-4 border border-outline-variant-30 bg-surface-container p-5 transition-colors duration-150 hover:border-outline" data-reveal={isInView} @@ -110,6 +112,7 @@ export default function TrustStrip() { = { + all: 1.0, + en: 0.97, + es: 1.06, +}; + +/** Helper to generate 30 days of consistent, realistic rolling historical RUM + * data, split by locale. This is development/demo fixture data — not production + * telemetry. */ export function generateRolling30DayData(): Record< string, - Record + Record> > { const seedMultiplier: Record = { '/': 0.85, @@ -110,39 +124,52 @@ export function generateRolling30DayData(): Record< }; const pages = SITE_PAGES.filter((p) => p !== 'All Pages'); - const result: Record> = {}; + const result: Record< + string, + Record> + > = {}; pages.forEach((page) => { const mult = seedMultiplier[page] || 1.0; + const perLocale = {} as Record< + VitalsLocale, + Record + >; - // LCP around 1.1s - 1.8s - const lcpValues = Array.from({ length: 30 }, (_, i) => { - const noise = (Math.sin(i * 0.7) * 0.15 + Math.cos(i * 0.4) * 0.1) * mult; - return Number(Math.max(0.6, 1.25 + noise).toFixed(2)); - }); + (VITALS_LOCALES as readonly VitalsLocale[]).forEach((locale) => { + const lmult = mult * LOCALE_SEED[locale]; - // INP around 32ms - 65ms - const inpValues = Array.from({ length: 30 }, (_, i) => { - const noise = (Math.cos(i * 0.5) * 8 + Math.sin(i * 0.3) * 5) * mult; - return Math.max(12, Math.round(42 + noise)); - }); + // LCP around 1.1s - 1.8s + const lcpValues = Array.from({ length: 30 }, (_, i) => { + const noise = (Math.sin(i * 0.7) * 0.15 + Math.cos(i * 0.4) * 0.1) * lmult; + return Number(Math.max(0.6, 1.25 + noise).toFixed(2)); + }); - // CLS around 0.005 - 0.03 - const clsValues = Array.from({ length: 30 }, (_, i) => { - const noise = (Math.sin(i * 0.9) * 0.004 + Math.cos(i * 0.2) * 0.003) * mult; - return Number(Math.max(0.001, 0.012 + noise).toFixed(3)); - }); + // INP around 32ms - 65ms + const inpValues = Array.from({ length: 30 }, (_, i) => { + const noise = (Math.cos(i * 0.5) * 8 + Math.sin(i * 0.3) * 5) * lmult; + return Math.max(12, Math.round(42 + noise)); + }); + + // CLS around 0.005 - 0.03 + const clsValues = Array.from({ length: 30 }, (_, i) => { + const noise = (Math.sin(i * 0.9) * 0.004 + Math.cos(i * 0.2) * 0.003) * lmult; + return Number(Math.max(0.001, 0.012 + noise).toFixed(3)); + }); + + const sampleCounts = Array.from({ length: 30 }, (_, i) => { + const baseSamples = Math.round(140 + Math.sin(i * 0.5) * 40); + return Math.max(50, baseSamples); + }); - const sampleCounts = Array.from({ length: 30 }, (_, i) => { - const baseSamples = Math.round(140 + Math.sin(i * 0.5) * 40); - return Math.max(50, baseSamples); + perLocale[locale] = { + LCP: { values: lcpValues, samples: sampleCounts }, + INP: { values: inpValues, samples: sampleCounts }, + CLS: { values: clsValues, samples: sampleCounts }, + }; }); - result[page] = { - LCP: { values: lcpValues, samples: sampleCounts }, - INP: { values: inpValues, samples: sampleCounts }, - CLS: { values: clsValues, samples: sampleCounts }, - }; + result[page] = perLocale; }); return result; @@ -169,6 +196,7 @@ export function getVitalsSummary( metric: MetricType, selectedPage: string = 'All Pages', liveOverrides?: { page: string; metric: MetricType; value: number }[], + locale: VitalsLocale = 'all', ): MetricSummary { const today = new Date(); const pagesToInclude = @@ -187,9 +215,10 @@ export function getVitalsSummary( pagesToInclude.forEach((page) => { const pageData = HISTORICAL_DATA_STORE[page]; - if (pageData && pageData[metric]) { - const val = pageData[metric].values[29 - i] ?? 0; - const samp = pageData[metric].samples[29 - i] ?? 100; + const localeData = pageData?.[locale] ?? pageData?.all; + if (localeData && localeData[metric]) { + const val = localeData[metric].values[29 - i] ?? 0; + const samp = localeData[metric].samples[29 - i] ?? 100; dayValues.push(val); daySamples += samp; } diff --git a/src/data/vitalsFixtures.ts b/src/data/vitalsFixtures.ts new file mode 100644 index 0000000..c283347 --- /dev/null +++ b/src/data/vitalsFixtures.ts @@ -0,0 +1,74 @@ +/** + * Synthetic dashboard fixtures for the /vitals v2 dashboard. + * + * These are DEVELOPMENT/DEMO fixtures, not production telemetry. They give the + * conversion tiles and incident overlay a realistic shape so the UI can later be + * wired to the real first-party analytics API (Plausible) and status feed + * (issue #10) without restructuring. + */ + +export interface ConversionTile { + event: string; + label: string; + /** Conversions over the trailing 30-day window (fixture value). */ + conversions30d: number; + isFixture: true; + /** When true, no UI emitter exists yet — instrumentation is blocked. */ + blocked?: boolean; + note?: string; +} + +/** + * Conversion tiles. Only events that have an actual emitter in the app carry a + * non-zero fixture count. `chain_matrix_sort` is typed but has no UI, so it is + * explicitly marked blocked. + */ +export const CONVERSION_FIXTURES: ConversionTile[] = [ + { event: 'cta_click', label: 'CTA Clicks', conversions30d: 4821, isFixture: true }, + { + event: 'newsletter_submit', + label: 'Newsletter Signups', + conversions30d: 312, + isFixture: true, + }, + { + event: 'blog_post_read', + label: 'Blog Reads (80% depth)', + conversions30d: 1190, + isFixture: true, + }, + { event: 'outbound_click', label: 'Outbound Clicks', conversions30d: 2640, isFixture: true }, + { + event: 'calculator_share', + label: 'Calculator Shares', + conversions30d: 184, + isFixture: true, + }, + { + event: 'chain_matrix_sort', + label: 'Chain Matrix Sorts', + conversions30d: 0, + isFixture: true, + blocked: true, + note: 'No sortable chain matrix UI exists in the app.', + }, +]; + +export interface IncidentRecord { + /** ISO date (YYYY-MM-DD) the incident started. */ + date: string; + /** ISO date (YYYY-MM-DD) the incident was resolved. */ + resolvedDate: string; + title: string; + severity: 'minor' | 'major' | 'critical'; +} + +/** + * Last-30-day incident history for the overlay layer. + * + * Real incident history is BLOCKED by issue #10 (no status/incident feed exists + * in the repository). The array is intentionally empty so the dashboard renders + * its empty state. The overlay boundary (rendering + empty state) is in place + * and will populate automatically when a real feed is connected. + */ +export const INCIDENT_FIXTURES: IncidentRecord[] = []; diff --git a/src/hooks/useVitals.ts b/src/hooks/useVitals.ts index 9b184d1..57c69ed 100644 --- a/src/hooks/useVitals.ts +++ b/src/hooks/useVitals.ts @@ -1,11 +1,13 @@ import { useEffect, useState, useCallback } from 'react'; import { trackEvent } from '../analytics'; +import { isDNTEnabled } from '../utils/privacy'; import { MetricType, MetricRating, getRating, getVitalsSummary, MetricSummary, + VitalsLocale, } from '../data/vitalsData'; export interface RecordedVital { @@ -16,24 +18,9 @@ export interface RecordedVital { timestamp: number; } -/** - * Checks whether the user has enabled Do Not Track (DNT) or Global Privacy Control (GPC) - * in their browser settings. - */ -export function isDNTEnabled(): boolean { - if (typeof window === 'undefined') return false; - - const nav = window.navigator as { - doNotTrack?: string | null; - globalPrivacyControl?: boolean; - }; - const win = window as { doNotTrack?: string | null }; - - const dnt = nav.doNotTrack ?? win.doNotTrack; - const gpc = nav.globalPrivacyControl; - - return dnt === '1' || dnt === 'yes' || gpc === true; -} +// Re-exported from the shared privacy gate so existing imports keep working. +// The canonical implementation now lives in `src/utils/privacy.ts`. +export { isDNTEnabled } from '../utils/privacy'; export function useVitals() { const [dntEnabled, setDntEnabled] = useState(false); @@ -138,13 +125,17 @@ export function useVitals() { }, [recordVital]); const getSummary = useCallback( - (metric: MetricType, page: string = 'All Pages'): MetricSummary => { + ( + metric: MetricType, + page: string = 'All Pages', + locale: VitalsLocale = 'all', + ): MetricSummary => { const liveOverrides = recordedVitals.map((v) => ({ page: v.page, metric: v.metric, value: v.value, })); - return getVitalsSummary(metric, page, liveOverrides); + return getVitalsSummary(metric, page, liveOverrides, locale); }, [recordedVitals], ); diff --git a/src/pages/About.tsx b/src/pages/About.tsx index c536d5a..ad0efd2 100644 --- a/src/pages/About.tsx +++ b/src/pages/About.tsx @@ -1,4 +1,5 @@ import { Helmet } from 'react-helmet-async'; +import { trackOutbound } from '../utils/track'; import teamData from '../data/team.json'; const socialIcon = (type: 'github' | 'twitter') => { @@ -115,6 +116,7 @@ export default function About() { diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index 36eb060..074590b 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link, useParams } from 'react-router-dom'; import { @@ -14,6 +15,8 @@ import { } from '../utils/blog'; import BlogToc from '../components/BlogToc'; import { article, breadcrumbList, SITE_URL } from '../utils/jsonld'; +import { track } from '../utils/track'; +import i18n from '../i18n'; function AuthorByline({ post }: { post: BlogPost }) { if (!post.author) return null; @@ -35,6 +38,12 @@ function AuthorByline({ post }: { post: BlogPost }) { ); } +/** Normalizes an i18n language tag to a supported analytics locale. */ +function normalizeLocale(lang: string | undefined): string { + if (lang === 'es') return 'es'; + return 'en'; +} + function BlogList() { const posts = getAllPosts(); @@ -114,6 +123,50 @@ function BlogList() { function BlogPostDetail({ slug }: { slug: string }) { const post = getPostBySlug(slug); + // One-shot guard: ensures a single `blog_post_read` per article page view, + // independent of re-renders or continued scrolling. + const readFiredRef = useRef(false); + + useEffect(() => { + if (!post) return; + + // Reset for a new article view. + readFiredRef.current = false; + + const fire = () => { + if (readFiredRef.current) return; + readFiredRef.current = true; + track('blog_post_read', { + slug: post.slug, + locale: normalizeLocale(i18n.language), + }); + window.removeEventListener('scroll', onScroll); + }; + + const onScroll = () => { + const doc = document.documentElement; + const scrollHeight = doc.scrollHeight - doc.clientHeight; + + // Short pages: content already fits the viewport, so the 80% threshold + // is considered satisfied immediately. + if (scrollHeight <= 0) { + fire(); + return; + } + + const scrollTop = window.scrollY || doc.scrollTop || 0; + const percent = (scrollTop / scrollHeight) * 100; + if (percent >= 80) { + fire(); + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + // Evaluate immediately (handles short pages and already-scrolled restores). + onScroll(); + + return () => window.removeEventListener('scroll', onScroll); + }, [post, slug]); if (!post) { return ( diff --git a/src/pages/Careers.tsx b/src/pages/Careers.tsx index eaf6ebe..cd9e129 100644 --- a/src/pages/Careers.tsx +++ b/src/pages/Careers.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react'; import { Helmet } from 'react-helmet-async'; import { trackEvent } from '../analytics'; +import { track } from '../utils/track'; type SubmitStatus = 'idle' | 'loading' | 'success' | 'error'; @@ -172,7 +173,7 @@ export default function Careers() { href="https://github.com/wraith-protocol/www/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Careers Open Issues CTA')} + onClick={() => track('cta_click', { source: 'careers-open-issues' })} className="group flex flex-col gap-4 border border-outline-variant bg-surface-container p-7 transition-colors duration-150 hover:bg-surface-bright" > diff --git a/src/pages/Contributors.tsx b/src/pages/Contributors.tsx index 8c39f35..cc6c559 100644 --- a/src/pages/Contributors.tsx +++ b/src/pages/Contributors.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { trackOutbound } from '../utils/track'; import contributorsData from '../data/contributors.json'; interface Contributor { @@ -42,6 +43,7 @@ export default function Contributors() { key={contributor.username} href={contributor.profile} target="_blank" + onClick={trackOutbound('github')} rel="noopener noreferrer" className="flex items-center p-6 bg-surface rounded-xl border border-border hover:border-primary transition-all hover:-translate-y-1 hover:shadow-lg hover:shadow-primary/10 group" > diff --git a/src/pages/Ecosystem.tsx b/src/pages/Ecosystem.tsx index 8f2364b..bc2e701 100644 --- a/src/pages/Ecosystem.tsx +++ b/src/pages/Ecosystem.tsx @@ -1,6 +1,7 @@ import { Helmet } from 'react-helmet-async'; import { Link } from 'react-router-dom'; import { useInView } from '../hooks/useInView'; +import { trackOutbound } from '../utils/track'; import ecosystemData from '../data/ecosystem.json'; const categories = ecosystemData.categories; @@ -22,6 +23,7 @@ function PartnerCard({ diff --git a/src/pages/Grants.tsx b/src/pages/Grants.tsx index 2b0d67e..946d8ea 100644 --- a/src/pages/Grants.tsx +++ b/src/pages/Grants.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { trackOutbound } from '../utils/track'; import waveData from '../data/wave.json'; import { howTo, SITE_URL } from '../utils/jsonld'; @@ -65,6 +66,7 @@ export default function Grants() { @@ -119,6 +121,7 @@ export default function Grants() { diff --git a/src/pages/Newsletter.tsx b/src/pages/Newsletter.tsx index 4b0f06a..8802c6f 100644 --- a/src/pages/Newsletter.tsx +++ b/src/pages/Newsletter.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Footer from '../components/Footer'; +import { track } from '../utils/track'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -23,6 +24,9 @@ export default function Newsletter() { const { t } = useTranslation(); const [email, setEmail] = useState(''); const [formState, setFormState] = useState('idle'); + // Exactly-once guard: prevents duplicate submissions (and therefore duplicate + // conversion telemetry) from rapid repeat clicks on the submit button. + const submittedRef = useRef(false); const errorMessage = (): string | null => { switch (formState) { @@ -40,6 +44,9 @@ export default function Newsletter() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // Exactly-once: ignore repeat submits while one is already in flight or done. + if (submittedRef.current) return; + const trimmed = email.trim().toLowerCase(); if (!trimmed || !EMAIL_RE.test(trimmed)) { @@ -56,7 +63,13 @@ export default function Newsletter() { body: JSON.stringify({ email: trimmed }), }); + // 201 = subscription accepted by the backend and a double opt-in + // confirmation email has been queued by Buttondown. This is the successful + // submission conversion. (The actual email-confirmation step is handled by + // Buttondown's email flow, which is outside this SPA — see newsletter_confirm.) if (res.status === 201) { + submittedRef.current = true; + track('newsletter_submit', { source: 'newsletter-page' }); setFormState('success'); return; } diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index b27747f..bb9a8d5 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { trackOutbound } from '../utils/track'; import Footer from '../components/Footer'; type Suggestion = { @@ -97,6 +98,7 @@ export default function NotFound() { key={item.label} href={item.href} target="_blank" + onClick={trackOutbound('other')} rel="noopener noreferrer" className="flex flex-col gap-1 border border-outline-variant bg-surface-container px-4 py-4 transition-colors hover:border-outline" > @@ -135,6 +137,7 @@ export default function NotFound() { diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index c84c39b..344071f 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -1,21 +1,65 @@ import { Link } from 'react-router-dom'; import PrivacyComparison from '../components/PrivacyComparison'; +import { trackOutbound } from '../utils/track'; + +const ANALYTICS_ENDPOINT = 'https://plausible.io/api/event'; const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( -
    +

    {title}

    {children}
    -
    + ); +function EventRow({ + name, + payload, + trigger, + status = 'Emitted by this site', +}: { + name: string; + payload: string; + trigger: string; + status?: string; +}) { + return ( +
    +

    {name}

    +
      +
    • + Trigger: {trigger} +
    • +
    • + Payload:{' '} + {payload} +
    • +
    • + Retention: aggregate event data retained by + Plausible under the project's analytics retention configuration. +
    • +
    • + Endpoint:{' '} + {ANALYTICS_ENDPOINT} +
    • +
    • + DNT/GPC: suppressed before the analytics + script or event request is sent. +
    • +
    • + Status: {status}. +
    • +
    +
    + ); +} + export default function Privacy() { return (
    - {/* minimal nav */}
    Wraith @@ -27,7 +71,6 @@ export default function Privacy() {
    - {/* header */}
    Legal @@ -35,19 +78,15 @@ export default function Privacy() {

    Privacy Policy

    -

    - Last updated: June 2025  ·  usewraith.xyz -

    +

    usewraith.xyz

    - {/* intro */}

    - Wraith Protocol is a privacy-first project. We apply the same principle to this website: - collect only what we need to improve the product, and nothing that could identify you - personally. + Wraith Protocol is privacy-first. We collect only aggregate product and performance + telemetry needed to understand the site, and we do not include wallet data, form + contents, or other personally identifying values in analytics events.

    - {/* interactive comparison */}
    @@ -59,114 +98,98 @@ export default function Privacy() {
    -
    +

    We use{' '} Plausible Analytics {' '} - — an open-source, EU-hosted analytics platform — to understand how visitors interact - with this site. + for aggregate page and event analytics. Custom events are delivered to exactly{' '} + {ANALYTICS_ENDPOINT} + . No second analytics provider or tag manager is introduced by this instrumentation.

    -

    - Plausible collects the following{' '} - aggregate data per page visit: -

    -
      -
    • Page URL and referrer
    • -
    • Browser name and version (no fingerprinting)
    • -
    • Operating system
    • -
    • Country and region (derived from IP; the IP itself is never stored)
    • -
    • Device type (desktop / tablet / mobile)
    • -
    • Scroll depth percentage
    • -
    • - Goal events: “Read the Docs”, “Try the Demo”, “Get API - Key”, “Code Tab Change” -
    • -
    -
    -
      -
    • No cookies are set — ever.
    • -
    • No persistent identifiers or device fingerprints.
    • -
    • No cross-site tracking.
    • -
    • No IP addresses stored or logged.
    • -
    • No personal information (name, email, wallet address, etc.).
    • -
    +

    - Because Plausible is cookieless,{' '} - no consent banner is required under GDPR, - PECR, or ePrivacy Directive. See Plausible's own{' '} - - data policy - {' '} - for the full breakdown. + Do-Not-Track and Global Privacy Control are checked before analytics loads. If either + signal opts the visitor out, the Plausible script is not requested and named analytics + events and Web Vitals do not make requests to the analytics endpoint.

    -
    -

    We chose Plausible over Google Analytics or other trackers because it is:

    +
      -
    • - Cookieless by design — the script uses - a daily rotating hash, not a persistent cookie or localStorage value. -
    • -
    • - EU-hosted — data is processed on - servers in the EU (Hetzner, Germany/Finland). No data transfer to the US. -
    • -
    • - Open source — the full codebase is - auditable at{' '} - - github.com/plausible/analytics - - . -
    • -
    • - Lightweight — the tracking script is - under 2 KB gzipped, adding no meaningful latency. -
    • +
    • No wallet or stealth addresses.
    • +
    • No transaction hashes or transaction amounts.
    • +
    • No newsletter email address or form contents.
    • +
    • No persistent cross-site identifier or fingerprint.
    • +
    • No full outbound destination URL in custom event payloads.
    -
    +

    - Beyond Plausible, this site loads fonts from{' '} - - Google Fonts - - . Google Fonts requests include your IP address; you can block them with a content - blocker if you prefer. No other third-party scripts are loaded. + The following names and payloads are defined by the typed analytics helper. Events + marked reserved have no corresponding UI in the current repository and therefore are + intentionally not emitted until that product surface exists.

    + +
    + + + + + + + + +

    - Under GDPR you have the right to access, rectify, and erase personal data held about - you. Because we store no personal data, there is nothing to access, rectify, or erase. - If you have questions, reach us at{' '} + If you have privacy questions, contact{' '} privacy@usewraith.xyz @@ -174,13 +197,6 @@ export default function Privacy() {

    -
    -

    - We may update this page when our data practices change. The date at the top of this - page reflects the most recent revision. -

    -
    -
    + {/* Analytics & Privacy */} +
    +

    + Analytics & Privacy Controls +

    +
    +

    + The site uses the existing Plausible integration only. Named events are sent to{' '} + + https://plausible.io/api/event + {' '} + through the typed event boundary in{' '} + src/utils/track.ts. No new + analytics provider, tag manager, or tracking pixel is introduced. Payloads are + flattened to string, number, and boolean properties, and undefined fields are + discarded. +

    +

    + Do-Not-Track and Global Privacy Control are enforced before the analytics script loads + and before any named event or Web Vital is sent. With either preference active, the + browser makes no request to the analytics endpoint. Named events and Web Vitals share + the privacy gate in{' '} + src/utils/privacy.ts. +

    +
    +

    + Event inventory and payloads +

    +
    +
    +
    cta_click
    +
    + {' '} + — source: string +
    +
    +
    +
    newsletter_submit
    +
    + {' '} + — source: string +
    +
    +
    +
    newsletter_confirm
    +
    + {' '} + — source: string +
    +
    +
    +
    blog_post_read
    +
    + {' '} + — slug: string; locale?: string +
    +
    +
    +
    calculator_share
    +
    + {' '} + — source?: string +
    +
    +
    +
    chain_matrix_sort
    +
    + {' '} + —{' '} + + column: string; direction: 'asc' | 'desc' + +
    +
    +
    +
    outbound_click
    +
    + {' '} + —{' '} + + category: github | docs | social | explorer | ecosystem | partner | other + +
    +
    +
    +
    +

    + Payloads exclude wallet and stealth addresses, transaction hashes and amounts, + newsletter form contents, full outbound URLs, secrets, and unrelated data. Aggregate + event data follows the project's Plausible retention configuration. See the{' '} + + Privacy Policy + {' '} + for trigger and retention details. +

    +
    +
    + {/* Related Pages */}

    Related Pages

    diff --git a/src/pages/Stellar.tsx b/src/pages/Stellar.tsx index d976278..7e53929 100644 --- a/src/pages/Stellar.tsx +++ b/src/pages/Stellar.tsx @@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet-async'; import { Link } from 'react-router-dom'; import { useInView } from '../hooks/useInView'; import { trackEvent } from '../analytics'; +import { track, trackOutbound } from '../utils/track'; import EcosystemPartners from '../components/EcosystemPartners'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -273,7 +274,7 @@ export default function Stellar() { href="https://demo.usewraith.xyz/stellar" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Stellar Demo CTA')} + onClick={() => track('cta_click', { source: 'stellar-demo' })} className="flex h-12 items-center justify-center bg-primary px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-surface transition-[filter] duration-150 hover:brightness-110" > Try Stellar Demo @@ -282,7 +283,7 @@ export default function Stellar() { href="https://docs.usewraith.xyz/chains/stellar" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Stellar Docs CTA')} + onClick={() => track('cta_click', { source: 'stellar-docs' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Read Stellar docs @@ -291,7 +292,7 @@ export default function Stellar() { href="https://spectre.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Spectre on Stellar CTA')} + onClick={() => track('cta_click', { source: 'stellar-spectre' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Spectre on Stellar @@ -454,9 +455,7 @@ export default function Stellar() { href={c.explorer} target="_blank" rel="noopener noreferrer" - onClick={() => - trackEvent('Stellar Explorer Link', { props: { contract: c.name } }) - } + onClick={trackOutbound('explorer')} className="font-mono text-[11px] font-semibold tracking-[1px] text-tertiary hover:brightness-110 transition-[filter]" > View ↗ @@ -603,7 +602,7 @@ export default function Stellar() { href="https://demo.usewraith.xyz/stellar" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Stellar Demo CTA bottom')} + onClick={() => track('cta_click', { source: 'stellar-demo' })} className="flex h-12 items-center justify-center bg-primary px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-surface transition-[filter] duration-150 hover:brightness-110" > Try Stellar Demo @@ -612,7 +611,7 @@ export default function Stellar() { href="https://docs.usewraith.xyz/chains/stellar" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Stellar Docs CTA bottom')} + onClick={() => track('cta_click', { source: 'stellar-docs' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Read Stellar docs @@ -621,7 +620,7 @@ export default function Stellar() { href="https://spectre.usewraith.xyz" target="_blank" rel="noopener noreferrer" - onClick={() => trackEvent('Spectre CTA bottom')} + onClick={() => track('cta_click', { source: 'stellar-spectre' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Spectre on Stellar @@ -649,6 +648,7 @@ export default function Stellar() { key={credit.name} href={credit.url} target="_blank" + onClick={trackOutbound('other')} rel="noopener noreferrer" className="font-mono text-[11px] text-on-surface-variant hover:text-primary transition-colors duration-150" > diff --git a/src/pages/Vitals.tsx b/src/pages/Vitals.tsx index a6d5d0d..9fd6118 100644 --- a/src/pages/Vitals.tsx +++ b/src/pages/Vitals.tsx @@ -8,7 +8,10 @@ import { SITE_PAGES, SitePage, MetricRating, + VITALS_LOCALES, + VitalsLocale, } from '../data/vitalsData'; +import { CONVERSION_FIXTURES, INCIDENT_FIXTURES } from '../data/vitalsFixtures'; function getRatingBadge(rating: MetricRating) { switch (rating) { @@ -38,12 +41,13 @@ export default function Vitals() { const [selectedMetric, setSelectedMetric] = useState('LCP'); const [selectedPage, setSelectedPage] = useState('All Pages'); const [hoveredPointIndex, setHoveredPointIndex] = useState(null); + const [selectedLocale, setSelectedLocale] = useState('all'); - const lcpSummary = getSummary('LCP', selectedPage); - const inpSummary = getSummary('INP', selectedPage); - const clsSummary = getSummary('CLS', selectedPage); + const lcpSummary = getSummary('LCP', selectedPage, selectedLocale); + const inpSummary = getSummary('INP', selectedPage, selectedLocale); + const clsSummary = getSummary('CLS', selectedPage, selectedLocale); - const activeSummary = getSummary(selectedMetric, selectedPage); + const activeSummary = getSummary(selectedMetric, selectedPage, selectedLocale); const activeDef = METRIC_DEFINITIONS[selectedMetric]; // SVG chart layout math @@ -207,26 +211,49 @@ export default function Vitals() { ))}
    - {/* Page Filter Selector */} -
    - - + {/* Page + Locale Filter Selectors */} +
    +
    + + +
    + +
    + + +
    @@ -360,6 +387,101 @@ export default function Vitals() {
    + {/* Conversion Tiles (synthetic fixtures) */} +
    +
    +
    +

    + Conversion Events +

    +

    + Trailing 30-day event volume ·{' '} + synthetic fixture data +

    +
    + + Fixture + +
    +
    + {CONVERSION_FIXTURES.map((tile) => ( +
    + + {tile.event} + + + {tile.blocked ? '—' : tile.conversions30d.toLocaleString()} + + {tile.label} + {tile.blocked && ( + + Blocked + + )} +
    + ))} +
    +
    + + {/* Incident Overlay (last 30 days) */} +
    +
    +
    +

    Incidents

    +

    + Last 30 days · overlaid on performance +

    +
    + + {INCIDENT_FIXTURES.length} active + +
    + + {INCIDENT_FIXTURES.length === 0 ? ( +
    +
    + ) : ( +
      + {INCIDENT_FIXTURES.map((inc) => ( +
    • +
      + {inc.title} + + {inc.date} → {inc.resolvedDate} + +
      + + {inc.severity} + +
    • + ))} +
    + )} +
    + {/* Metric Documentation Section */}
    diff --git a/src/utils/blog.ts b/src/utils/blog.ts index 34d7e59..e199b45 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -95,7 +95,18 @@ const rawModules = import.meta.glob('/src/content/blog/*.mdx', { query: '?raw', import: 'default', eager: true, -}) as Record; +}) as Record; + +function getRawContent(value: unknown): string { + if (typeof value === 'string') return value; + + if (value && typeof value === 'object' && 'default' in value) { + const defaultExport = (value as { default?: unknown }).default; + if (typeof defaultExport === 'string') return defaultExport; + } + + return ''; +} // Cache posts to avoid re-parsing on every call let cachedPosts: BlogPost[] | null = null; @@ -111,7 +122,7 @@ export function getAllPosts(): BlogPost[] { const rawAuthor = frontmatter.author || ''; const { name: authorName, linkId: authorId } = resolveAuthor(rawAuthor); - const rawContent = rawModules[filepath] || ''; + const rawContent = getRawContent(rawModules[filepath]); const body = rawContent.replace(/^---[\s\S]*?^---/m, ''); const words = body.split(/\s+/).filter(Boolean).length; const readingTimeMin = Math.max(1, Math.ceil(words / 200)); diff --git a/src/utils/privacy.ts b/src/utils/privacy.ts new file mode 100644 index 0000000..b94904e --- /dev/null +++ b/src/utils/privacy.ts @@ -0,0 +1,29 @@ +/** + * Shared privacy gate for first-party analytics. + * + * Centralizes Do-Not-Track (DNT) and Global Privacy Control (GPC) detection so + * every analytics surface respects the same signal: + * - named events via `track()` in `src/utils/track.ts` + * - Web Vitals via `useVitals` in `src/hooks/useVitals.ts` + * + * This module owns the canonical implementation. No caller should re-implement + * the check; named analytics automatically respect it through `track()`, and + * Web Vitals reuse `isDNTEnabled()` from here. + */ + +export function isDNTEnabled(): boolean { + if (typeof window === 'undefined' || typeof window.navigator === 'undefined') { + return false; + } + + const nav = window.navigator as { + doNotTrack?: string | null; + globalPrivacyControl?: boolean; + }; + const win = window as { doNotTrack?: string | null }; + + const dnt = nav.doNotTrack ?? win.doNotTrack; + const gpc = nav.globalPrivacyControl; + + return dnt === '1' || dnt === 'yes' || gpc === true; +} diff --git a/src/utils/track.test.ts b/src/utils/track.test.ts new file mode 100644 index 0000000..88a05e3 --- /dev/null +++ b/src/utils/track.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { track, trackOutbound } from './track'; +import * as analytics from '../analytics'; + +describe('track (typed analytics helper)', () => { + const originalNavigator = window.navigator; + + beforeEach(() => { + vi.spyOn(analytics, 'trackEvent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + + function setDNT(value: string | null) { + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator, doNotTrack: value }, + configurable: true, + }); + } + + it('forwards a named event with flat props to the analytics endpoint', () => { + setDNT('0'); + track('cta_click', { source: 'hero-docs' }); + + expect(analytics.trackEvent).toHaveBeenCalledWith('cta_click', { + props: { source: 'hero-docs' }, + }); + }); + + it('drops explicitly undefined optional fields from the payload', () => { + setDNT('0'); + track('blog_post_read', { slug: 'wave-7-kickoff' }); + + expect(analytics.trackEvent).toHaveBeenCalledWith('blog_post_read', { + props: { slug: 'wave-7-kickoff' }, + }); + }); + + it('preserves union-typed payload fields such as sort direction', () => { + setDNT('0'); + track('chain_matrix_sort', { column: 'latency', direction: 'desc' }); + + expect(analytics.trackEvent).toHaveBeenCalledWith('chain_matrix_sort', { + props: { column: 'latency', direction: 'desc' }, + }); + }); + + it('suppresses all events when Do-Not-Track is enabled', () => { + setDNT('1'); + track('cta_click', { source: 'hero-docs' }); + track('newsletter_submit', { source: 'newsletter-page' }); + + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + + it('dispatches events when Do-Not-Track is disabled', () => { + setDNT('0'); + track('newsletter_confirm', { source: 'newsletter-page' }); + + expect(analytics.trackEvent).toHaveBeenCalledOnce(); + }); + + it('trackOutbound emits exactly one outbound_click with the category', () => { + setDNT('0'); + const handler = trackOutbound('github'); + handler(); + + expect(analytics.trackEvent).toHaveBeenCalledWith('outbound_click', { + props: { category: 'github' }, + }); + }); + + it('trackOutbound is suppressed when Do-Not-Track is enabled', () => { + setDNT('1'); + const handler = trackOutbound('docs'); + handler(); + + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/track.ts b/src/utils/track.ts new file mode 100644 index 0000000..cddbf5a --- /dev/null +++ b/src/utils/track.ts @@ -0,0 +1,96 @@ +import { trackEvent } from '../analytics'; +import { isDNTEnabled } from './privacy'; + +/** + * Strongly-typed map of first-party analytics events and their payloads. + * + * These names are forwarded verbatim to the existing Plausible endpoint via + * `trackEvent`, so the event taxonomy stays centralized and auditable. + */ +export type AnalyticsEventMap = { + cta_click: { + source: string; + }; + + newsletter_submit: { + source: string; + }; + + newsletter_confirm: { + source: string; + }; + + blog_post_read: { + slug: string; + locale?: string; + }; + + calculator_share: { + source?: string; + }; + + chain_matrix_sort: { + column: string; + direction: 'asc' | 'desc'; + }; + + outbound_click: { + category: string; + }; +}; + +export type AnalyticsEventName = keyof AnalyticsEventMap; + +/** + * Categories for outbound external links actually present on the site. + * Used by `outbound_click`. + */ +export type OutboundCategory = + | 'github' + | 'docs' + | 'social' + | 'explorer' + | 'ecosystem' + | 'partner' + | 'other'; + +type AnalyticsProps = Record; + +/** Flattens a payload into Plausible props, dropping explicitly undefined fields. */ +function toProps(payload: AnalyticsEventMap[K]): AnalyticsProps { + const props: AnalyticsProps = {}; + + for (const [key, value] of Object.entries(payload)) { + if (value !== undefined) { + props[key] = value; + } + } + + return props; +} + +/** + * DNT-aware, strongly-typed wrapper around the first-party analytics endpoint. + * + * Respects Do-Not-Track / Global Privacy Control (GPC) before forwarding any + * named event, so no telemetry leaves the browser when the visitor opts out. + */ +export function track(event: K, payload: AnalyticsEventMap[K]): void { + if (isDNTEnabled()) { + return; + } + + trackEvent(event, { props: toProps(payload) }); +} + +/** + * Returns a click handler that emits exactly one `outbound_click` event for the + * given destination category while preserving native link navigation. + * + * DNT is respected automatically via `track()`. + */ +export function trackOutbound(category: OutboundCategory): () => void { + return () => { + track('outbound_click', { category }); + }; +} diff --git a/vite.config.ts b/vite.config.ts index a313e09..467e001 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,6 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: './src/test/setup.ts', - exclude: ['**/node_modules/**', '**/e2e/**'], + exclude: ['**/node_modules/**', '**/e2e/**', '**/tests/a11y/**'], }, });