diff --git a/e2e/newsletter.spec.ts b/e2e/newsletter.spec.ts
new file mode 100644
index 0000000..1f31a5d
--- /dev/null
+++ b/e2e/newsletter.spec.ts
@@ -0,0 +1,99 @@
+/**
+ * e2e/newsletter.spec.ts
+ *
+ * Acceptance criterion: zero *newsletter-specific* third-party network requests
+ * on /newsletter.
+ *
+ * The test intercepts every request made while the page loads and asserts that
+ * none of them target a cross-origin host *other than* the site's own analytics
+ * (Plausible), which is loaded on every page and was already present before this
+ * feature. The criterion is that the newsletter signup itself introduces no
+ * additional third-party scripts or resources.
+ *
+ * Allowed origins in preview mode:
+ * - localhost / 127.0.0.1 (Vite preview server)
+ * - plausible.io (site-wide cookieless analytics, pre-existing)
+ *
+ * data:, blob:, and other non-HTTP schemes are ignored.
+ */
+
+import { test, expect } from '@playwright/test';
+
+// Origins that are allowed on every page (pre-existing, not added by newsletter feature).
+const SITE_WIDE_ALLOWED = new Set(['plausible.io']);
+
+test.describe('/newsletter — zero cross-origin requests', () => {
+ test('loads the /newsletter page without any newsletter-specific third-party network requests', async ({
+ page,
+ baseURL,
+ }) => {
+ const crossOriginRequests: string[] = [];
+
+ const allowedHostnames = new Set(['localhost', '127.0.0.1', ...SITE_WIDE_ALLOWED]);
+
+ // Extract the hostname from the base URL so the test is portable.
+ if (baseURL) {
+ try {
+ allowedHostnames.add(new URL(baseURL).hostname);
+ } catch {
+ // ignore malformed baseURL
+ }
+ }
+
+ // Listen to every request the page fires.
+ page.on('request', (request) => {
+ const url = request.url();
+
+ // Ignore non-HTTP schemes (data:, blob:, about:, chrome-extension:, etc.)
+ if (!url.startsWith('http://') && !url.startsWith('https://')) return;
+
+ try {
+ const { hostname } = new URL(url);
+ if (!allowedHostnames.has(hostname)) {
+ crossOriginRequests.push(url);
+ }
+ } catch {
+ // Ignore unparseable URLs
+ }
+ });
+
+ await page.goto('/newsletter', { waitUntil: 'networkidle' });
+
+ // Assert no unexpected cross-origin requests were fired.
+ expect(
+ crossOriginRequests,
+ `Unexpected cross-origin requests detected on /newsletter:\n${crossOriginRequests.join('\n')}`,
+ ).toHaveLength(0);
+ });
+
+ test('renders the newsletter signup form with correct elements', async ({ page }) => {
+ await page.goto('/newsletter');
+
+ // Page heading is present
+ await expect(page.getByRole('heading', { name: /newsletter/i, level: 1 })).toBeVisible();
+
+ // Main page email input (not the footer widget) — identified by its id
+ await expect(page.locator('#newsletter-email')).toBeVisible();
+
+ // Submit button in the main form — scope to the section
+ await expect(page.getByRole('main').getByRole('button', { name: /subscribe/i })).toBeVisible();
+
+ // Privacy note links to /privacy
+ const privacyLink = page.getByRole('main').getByRole('link', { name: /privacy policy/i });
+ await expect(privacyLink).toBeVisible();
+ await expect(privacyLink).toHaveAttribute('href', '/privacy');
+ });
+
+ test('shows inline validation error for an invalid email', async ({ page }) => {
+ await page.goto('/newsletter');
+
+ // Fill the main newsletter form input (not the footer widget)
+ await page.locator('#newsletter-email').fill('not-an-email');
+ await page
+ .getByRole('main')
+ .getByRole('button', { name: /subscribe/i })
+ .click();
+
+ await expect(page.getByRole('alert').first()).toBeVisible();
+ });
+});
diff --git a/package.json b/package.json
index 1a613c0..1769e1a 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"og:generate": "tsx scripts/og.ts",
"preview": "vite preview",
"test": "vitest run",
+ "test:e2e": "playwright test",
"test:a11y": "vitest run src/__tests__/a11y.test.tsx",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -31,6 +32,7 @@
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@fontsource/space-grotesk": "^5.2.10",
+ "@playwright/test": "1.49.1",
"@resvg/resvg-js": "^2.6.2",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..4ec15ca
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://localhost:4173',
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: 'pnpm preview',
+ url: 'http://localhost:4173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 717d921..beb999d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,6 +45,9 @@ importers:
'@fontsource/space-grotesk':
specifier: ^5.2.10
version: 5.2.10
+ '@playwright/test':
+ specifier: 1.49.1
+ version: 1.49.1
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
@@ -627,6 +630,11 @@ packages:
'@oxc-project/types@0.124.0':
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
+ '@playwright/test@1.49.1':
+ resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
@@ -1415,6 +1423,11 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1772,6 +1785,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ playwright-core@1.49.1:
+ resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.49.1:
+ resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2633,6 +2656,10 @@ snapshots:
'@oxc-project/types@0.124.0': {}
+ '@playwright/test@1.49.1':
+ dependencies:
+ playwright: 1.49.1
+
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
@@ -3323,6 +3350,9 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -3631,6 +3661,14 @@ snapshots:
picomatch@4.0.4: {}
+ playwright-core@1.49.1: {}
+
+ playwright@1.49.1:
+ dependencies:
+ playwright-core: 1.49.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {}
diff --git a/scripts/og.ts b/scripts/og.ts
index fc698a0..51693bc 100644
--- a/scripts/og.ts
+++ b/scripts/og.ts
@@ -74,6 +74,12 @@ const routes: RouteConfig[] = [
title: 'Blog',
subtitle: 'Updates, guides, and deep dives from the Wraith team',
},
+ {
+ slug: 'newsletter',
+ routePath: '/newsletter',
+ title: 'Newsletter',
+ subtitle: 'Mainnet updates, security advisories, and grant news — no tracking',
+ },
];
function ogCard({ title, subtitle, chainBadge }: RouteConfig) {
diff --git a/src/App.tsx b/src/App.tsx
index 96c7183..c25d46c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,6 +23,7 @@ const Footer = lazy(() => import('./components/Footer'));
// Lazy load pages
const Faq = lazy(() => import('./pages/Faq'));
const Privacy = lazy(() => import('./pages/Privacy'));
+const Newsletter = lazy(() => import('./pages/Newsletter'));
const UseCases = lazy(() => import('./pages/UseCases'));
const Stellar = lazy(() => import('./pages/Stellar'));
const Roadmap = lazy(() => import('./pages/Roadmap'));
diff --git a/src/__tests__/newsletter.test.tsx b/src/__tests__/newsletter.test.tsx
new file mode 100644
index 0000000..d843712
--- /dev/null
+++ b/src/__tests__/newsletter.test.tsx
@@ -0,0 +1,255 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import Newsletter from '../pages/Newsletter';
+import Footer from '../components/Footer';
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/**
+ * NOTE: react-i18next is not initialised in the Vitest environment, so
+ * t('some.key') returns the raw key string. All queries below are written
+ * against the rendered HTML rather than translated strings so the suite
+ * remains fast and self-contained.
+ */
+
+function renderNewsletter() {
+ return render(
+
+
+ ,
+ );
+}
+
+function renderFooter() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ─── Newsletter page ──────────────────────────────────────────────────────────
+
+describe('Newsletter page', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 201,
+ json: async () => ({ ok: true }),
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the page heading and form elements', () => {
+ renderNewsletter();
+
+ // h1 heading is present (renders the raw i18n key)
+ expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
+ // email input is present (identified by its HTML id)
+ expect(document.getElementById('newsletter-email')).toBeInTheDocument();
+ // submit button is present
+ expect(
+ screen.getAllByRole('button', { name: /newsletter\.submit/i }).length,
+ ).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows an error message when submitting an empty email', async () => {
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
+ });
+
+ it('shows an error message when submitting a malformed email', async () => {
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ // Use the input directly via its id
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'not-an-email');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
+ });
+
+ it('calls /api/subscribe with the email on valid submission', async () => {
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'test@example.com');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ await waitFor(() => {
+ expect(vi.mocked(fetch)).toHaveBeenCalledWith(
+ '/api/subscribe',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'test@example.com' }),
+ }),
+ );
+ });
+ });
+
+ it('shows success state after a 201 response', async () => {
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'test@example.com');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('status')).toBeInTheDocument();
+ // Form should no longer be visible
+ expect(screen.queryByRole('button', { name: /newsletter\.submit/i })).not.toBeInTheDocument();
+ });
+
+ it('shows an "already subscribed" error on 409 response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: async () => ({ error: 'already_subscribed' }),
+ } as Response);
+
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'existing@example.com');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+
+ it('shows a generic error on 500 response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ } as Response);
+
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'test@example.com');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+
+ it('shows a generic error when fetch throws a network error', async () => {
+ vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
+
+ const user = userEvent.setup();
+ renderNewsletter();
+
+ const emailInput = document.getElementById('newsletter-email') as HTMLInputElement;
+ await user.type(emailInput, 'test@example.com');
+ await user.click(screen.getByRole('button', { name: /newsletter\.submit/i }));
+
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+
+ it('does not load any third-party scripts or resources', () => {
+ renderNewsletter();
+ // Confirm there are no