diff --git a/Jenkinsfile b/Jenkinsfile index 981e1f943c..e2764e69d8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -104,7 +104,6 @@ pipeline { "--build-arg NEXT_PUBLIC_SERVER_URL=https://${cmsDomain()} " + "--build-arg NEXT_PUBLIC_WEB_URL=https://${deployDomain()} " + "--build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY} " + - "--build-arg DEPLOYMENT_VERSION=${env.GIT_COMMIT} " + "-f ./apps/cms/Dockerfile ." ) } diff --git a/apps/cms/.env.docker.example b/apps/cms/.env.docker.example index 2ad24c05d9..dfc5a4e9d0 100644 --- a/apps/cms/.env.docker.example +++ b/apps/cms/.env.docker.example @@ -15,11 +15,9 @@ POSTGRES_DB=logos_cms # Generate with: openssl rand -hex 32 PAYLOAD_SECRET=change-me-openssl-rand-hex-32 -# --- Production build identity ---------------------------------------------- +# --- Production build key ---------------------------------------------------- # Generate with: openssl rand -base64 32 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=change-me-openssl-rand-base64-32 -# Prefer the deployed git SHA or release id. Changing this requires a rebuild. -DEPLOYMENT_VERSION=self-host # --- Public URLs (BAKED INTO THE CLIENT BUNDLE AT BUILD TIME) ----------------- # Must be the real public origins. Changing these requires a rebuild. diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile index ce66955dfa..6d023727ab 100644 --- a/apps/cms/Dockerfile +++ b/apps/cms/Dockerfile @@ -6,7 +6,6 @@ # # docker build -f apps/cms/Dockerfile \ # --build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$NEXT_SERVER_ACTIONS_ENCRYPTION_KEY" \ -# --build-arg DEPLOYMENT_VERSION="$(git rev-parse HEAD)" \ # --build-arg NEXT_PUBLIC_SERVER_URL=https://cms.example.com \ # --build-arg NEXT_PUBLIC_WEB_URL=https://www.example.com \ # -t logos-cms . @@ -41,7 +40,6 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ # builder — copy sources and build the Next.js production output. # ----------------------------------------------------------------------------- FROM base AS builder -ARG DEPLOYMENT_VERSION ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ARG NEXT_PUBLIC_SERVER_URL ARG NEXT_PUBLIC_WEB_URL @@ -49,7 +47,6 @@ ARG NEXT_PUBLIC_WEB_URL # load (during `next build`). DATABASE_URL/PAYLOAD_SECRET are NOT inlined and # are overridden with real values at runtime; the build never connects to the DB. ENV NODE_ENV=production \ - DEPLOYMENT_VERSION=${DEPLOYMENT_VERSION} \ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY} \ NEXT_TELEMETRY_DISABLED=1 \ NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL} \ diff --git a/apps/cms/README.md b/apps/cms/README.md index dbdd39d604..31ca649e8c 100644 --- a/apps/cms/README.md +++ b/apps/cms/README.md @@ -87,7 +87,6 @@ Required values in `.env.docker`: | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Credentials for the bundled Postgres. | | `PAYLOAD_SECRET` | `openssl rand -hex 32`. | | `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` | **Build time** stable Server Actions key. Generate with `openssl rand -base64 32`. Use the same value across CMS deployments. | -| `DEPLOYMENT_VERSION` | **Build time** deployment id, ideally the git SHA or release id. | | `NEXT_PUBLIC_SERVER_URL` | **Baked into the client bundle at build time** — must be the real public CMS origin. Changing it requires a rebuild. | | `NEXT_PUBLIC_WEB_URL` | **Build-time** public web origin (same caveat). | | `CMS_PORT` | Host port mapped to the container's `:3000` (default `3001`). | @@ -112,7 +111,6 @@ export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=' unknown +} + +const require = createRequire(import.meta.url) +const { loadEnvConfig } = require('@next/env') as NextEnv + +loadEnvConfig(process.cwd()) + const missing: string[] = [] if (!process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY) { missing.push('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY') } -if ( - !process.env.DEPLOYMENT_VERSION && - !process.env.NEXT_DEPLOYMENT_ID && - !process.env.VERCEL_GIT_COMMIT_SHA -) { - missing.push( - 'DEPLOYMENT_VERSION, NEXT_DEPLOYMENT_ID, or VERCEL_GIT_COMMIT_SHA' - ) -} - if (missing.length > 0) { console.error( `CMS production builds require ${missing.join( ', ' - )}. These values keep Next Server Actions stable across deployments.` + )}. This value keeps Next Server Actions stable across deployments.` ) process.exit(1) } diff --git a/apps/cms/src/config/__tests__/next-deployment.test.ts b/apps/cms/src/config/__tests__/next-deployment.test.ts index 4dde4d3ec6..1a0589b920 100644 --- a/apps/cms/src/config/__tests__/next-deployment.test.ts +++ b/apps/cms/src/config/__tests__/next-deployment.test.ts @@ -1,10 +1,18 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, it } from 'node:test' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) +const cmsRoot = process.cwd() +const buildEnvValidationScript = join( + cmsRoot, + 'scripts/validate-production-build-env.ts' +) +const tsxLoader = join(cmsRoot, 'node_modules/tsx/dist/esm/index.mjs') const importNextConfig = async ( overrides: Record @@ -52,7 +60,8 @@ const importNextConfig = async ( } const runBuildEnvValidation = async ( - overrides: Record + overrides: Record, + cwd = cmsRoot ): Promise<{ stderr: string; stdout: string }> => { const env = { ...process.env } for (const [key, value] of Object.entries(overrides)) { @@ -66,9 +75,9 @@ const runBuildEnvValidation = async ( try { const { stderr, stdout } = await execFileAsync( process.execPath, - ['--import', 'tsx', 'scripts/validate-production-build-env.ts'], + ['--import', tsxLoader, buildEnvValidationScript], { - cwd: process.cwd(), + cwd, env, } ) @@ -91,18 +100,27 @@ const runBuildEnvValidation = async ( describe('Next deployment configuration', () => { it('requires a stable Server Actions key for production builds', async () => { - const result = await runBuildEnvValidation({ - DEPLOYMENT_VERSION: 'ci', - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, - }) + const fixtureDir = await mkdtemp(join(tmpdir(), 'logos-cms-env-missing-')) - assert.match( - result.stderr, - /CMS production builds require NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/ - ) + try { + const result = await runBuildEnvValidation( + { + DEPLOYMENT_VERSION: 'ci', + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, + }, + fixtureDir + ) + + assert.match( + result.stderr, + /CMS production builds require NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/ + ) + } finally { + await rm(fixtureDir, { force: true, recursive: true }) + } }) - it('requires a deployment identifier for production builds', async () => { + it('accepts a stable Server Actions key without a deployment identifier', async () => { const result = await runBuildEnvValidation({ DEPLOYMENT_VERSION: undefined, NEXT_DEPLOYMENT_ID: undefined, @@ -110,104 +128,83 @@ describe('Next deployment configuration', () => { VERCEL_GIT_COMMIT_SHA: undefined, }) - assert.match( - result.stderr, - /CMS production builds require DEPLOYMENT_VERSION, NEXT_DEPLOYMENT_ID, or VERCEL_GIT_COMMIT_SHA/ - ) - }) - - it('accepts Vercel deployment ids as production build identifiers', async () => { - const result = await runBuildEnvValidation({ - DEPLOYMENT_VERSION: undefined, - NEXT_DEPLOYMENT_ID: 'dpl_C8QVLeEbYXbaNhbJjNG1Zw2J3Y33', - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - VERCEL_GIT_COMMIT_SHA: undefined, - }) - assert.equal(result.stderr, '') }) - it('uses the Vercel commit SHA as the deployment id', async () => { - const result = await importNextConfig({ - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - NODE_ENV: 'production', - NEXT_DEPLOYMENT_ID: undefined, - VERCEL_GIT_COMMIT_SHA: 'abc123', - }) + it('loads the stable Server Actions key from local env files', async () => { + const fixtureDir = await mkdtemp(join(tmpdir(), 'logos-cms-env-')) - assert.deepEqual(JSON.parse(result.stdout), { deploymentId: 'abc123' }) + try { + await writeFile( + join(fixtureDir, '.env'), + 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="n7o/dx+local-test-key="\n' + ) + + const result = await runBuildEnvValidation( + { + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, + }, + fixtureDir + ) + + assert.equal(result.stderr, '') + } finally { + await rm(fixtureDir, { force: true, recursive: true }) + } }) - it('uses the Vercel deployment id when Vercel provides one', async () => { + it('does not configure a deployment id from environment variables', async () => { const result = await importNextConfig({ - NEXT_DEPLOYMENT_ID: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', + DEPLOYMENT_VERSION: 'self-host-20260702', NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', NODE_ENV: 'production', + NEXT_DEPLOYMENT_ID: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', VERCEL_GIT_COMMIT_SHA: '5789cddea9cce53b639a79dfd5ccbc0eb19be56e', }) - assert.deepEqual(JSON.parse(result.stdout), { - deploymentId: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', - }) - }) - - it('uses the self-host deployment version as the deployment id', async () => { - const result = await importNextConfig({ - DEPLOYMENT_VERSION: 'self-host-20260702', - NEXT_DEPLOYMENT_ID: undefined, - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - NODE_ENV: 'production', - VERCEL_GIT_COMMIT_SHA: undefined, - }) - - assert.deepEqual(JSON.parse(result.stdout), { - deploymentId: 'self-host-20260702', - }) + assert.deepEqual(JSON.parse(result.stdout), { deploymentId: null }) }) }) describe('self-host deployment environment wiring', () => { - it('passes Server Actions and deployment identifiers into the Docker build', async () => { - const [composeFile, dockerfile, envExample] = await Promise.all([ + it('passes only the Server Actions key into the Docker build', async () => { + const [composeFile, dockerfile, envExample, jenkinsfile] = await Promise.all([ readFile('../../docker-compose.prod.yml', 'utf8'), readFile('Dockerfile', 'utf8'), readFile('.env.docker.example', 'utf8'), + readFile('../../Jenkinsfile', 'utf8'), ]) assert.match( composeFile, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: \$\{NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\}/ ) - assert.match(composeFile, /DEPLOYMENT_VERSION: \$\{DEPLOYMENT_VERSION/) + assert.doesNotMatch(composeFile, /DEPLOYMENT_VERSION/) assert.match(dockerfile, /ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/) - assert.match(dockerfile, /ARG DEPLOYMENT_VERSION/) + assert.doesNotMatch(dockerfile, /ARG DEPLOYMENT_VERSION/) assert.match( dockerfile, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=\$\{NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\}/ ) + assert.doesNotMatch(jenkinsfile, /DEPLOYMENT_VERSION/) assert.match(envExample, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=/) - assert.match(envExample, /DEPLOYMENT_VERSION=/) + assert.doesNotMatch(envExample, /DEPLOYMENT_VERSION/) }) - it('declares CMS deployment env vars for Turbo build tasks', async () => { + it('declares only the CMS Server Actions key for Turbo build tasks', async () => { const turboConfig = JSON.parse( await readFile('../../turbo.json', 'utf8') ) as { tasks: { build: { env: string[] } } } + assert.ok( + turboConfig.tasks.build.env.includes('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY') + ) assert.deepEqual( [ 'DEPLOYMENT_VERSION', - 'VERCEL_BRANCH_URL', 'NEXT_DEPLOYMENT_ID', - 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY', - 'PAYLOAD_DB_CONNECTION_TIMEOUT_MS', - 'PAYLOAD_DB_IDLE_TIMEOUT_MS', - 'PAYLOAD_DB_POOL_MAX', - 'PAYLOAD_DB_QUERY_TIMEOUT_MS', - 'PAYLOAD_HEALTH_TIMEOUT_MS', - 'VERCEL_ENV', 'VERCEL_GIT_COMMIT_SHA', - ].filter((name) => !turboConfig.tasks.build.env.includes(name)), + ].filter((name) => turboConfig.tasks.build.env.includes(name)), [] ) }) diff --git a/apps/cms/src/config/__tests__/payload-env.test.ts b/apps/cms/src/config/__tests__/payload-env.test.ts index 4414bff0c9..aefc534b6d 100644 --- a/apps/cms/src/config/__tests__/payload-env.test.ts +++ b/apps/cms/src/config/__tests__/payload-env.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' import { describe, it } from 'node:test' import { promisify } from 'node:util' @@ -198,6 +199,26 @@ describe('Payload production env guard', () => { }) }) +describe('Payload Vercel env propagation', () => { + it('exposes Payload runtime tuning env vars to the Turborepo build task', async () => { + const turboConfig = JSON.parse( + await readFile(new URL('../../../../../turbo.json', import.meta.url), 'utf8') + ) as { tasks?: { build?: { env?: string[] } } } + + const buildEnv = new Set(turboConfig.tasks?.build?.env ?? []) + + for (const name of [ + 'PAYLOAD_DB_POOL_MAX', + 'PAYLOAD_DB_CONNECTION_TIMEOUT_MS', + 'PAYLOAD_DB_QUERY_TIMEOUT_MS', + 'PAYLOAD_DB_IDLE_TIMEOUT_MS', + 'PAYLOAD_HEALTH_TIMEOUT_MS', + ]) { + assert.equal(buildEnv.has(name), true, `${name} must be listed in turbo.json build.env`) + } + }) +}) + describe('Payload admin document locking', () => { it('keeps the editable CMS collection list covered by the lock guard', async () => { const collections = await readPayloadCollections() diff --git a/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx b/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx new file mode 100644 index 0000000000..f6b5611ed0 --- /dev/null +++ b/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { ActivistBuilderCopySection } from '@repo/content/schemas' + +const sectionData: ActivistBuilderCopySection = { + componentType: 'activistBuilderCopy', + key: 'activistBuilder.copy', + heading: 'Activist Builder', + intro: 'Activist Builder intro.', + privacy: 'Activist Builder privacy.', + privacyLink: 'https://logos.co/privacy-policy', +} + +describe('ActivistBuilderPage – content-driven metadata', () => { + it('section data has the correct componentType', () => { + expect(sectionData.componentType).toBe('activistBuilderCopy') + }) + + it('section data has the correct heading', () => { + expect(sectionData.heading).toBe('Activist Builder') + }) + + it('renders heading in a simple element', () => { + const html = renderToStaticMarkup( + createElement('h1', null, sectionData.heading) + ) + expect(html).toContain('Activist Builder') + expect(html).toContain(' { + expect(sectionData.intro).toBe('Activist Builder intro.') + expect(sectionData.privacy).toBe('Activist Builder privacy.') + expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy') + }) +}) diff --git a/apps/web/app/[locale]/activist-builder/page.tsx b/apps/web/app/[locale]/activist-builder/page.tsx index d84b82413e..a937c57344 100644 --- a/apps/web/app/[locale]/activist-builder/page.tsx +++ b/apps/web/app/[locale]/activist-builder/page.tsx @@ -1,3 +1,7 @@ +import { getPageCopy } from '@repo/content/loaders' +import { isActiveLocale } from '@repo/content/locales' +import type { ActivistBuilderCopySection } from '@repo/content/schemas' + import { AfformPageIntro, ConnectFormSection, @@ -9,30 +13,42 @@ import { AFFORM, AFFORM_NAME, AFFORM_OPTIONS, - AFFORM_PAGE_INTRO, - AFFORM_PAGE_PRIVACY, - AFFORM_PAGE_PRIVACY_LINK, } from '@/lib/civicrm/afform-activist-builder' -import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata' +import { createPageMetadata } from '@/lib/page-metadata' +import { createSectionFinder } from '@/lib/page-sections' -const NAMESPACE = 'pages.activistBuilder' +const ROUTE = ROUTES.activistBuilder +const findSection = createSectionFinder('activist-builder') -export const generateMetadata = createTranslatedPageMetadata({ - namespace: NAMESPACE, - path: ROUTES.activistBuilder, -}) +export const generateMetadata = createPageMetadata(ROUTE) function getAfformSubmitApiUrl() { const base = env.NEXT_PUBLIC_CIVI_CRM_URL return base ? `${base.replace(/\/+$/, '')}/api/public/afform-submit` : '' } -export default function ActivistBuilderPage() { +export default async function ActivistBuilderPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + if (!isActiveLocale(locale)) { + throw new Error(`ActivistBuilderPage received non-active locale "${locale}"`) + } + + const page = await getPageCopy(ROUTE, locale) + const copy = findSection( + page.sections, + 'activistBuilderCopy', + 'activistBuilder.copy' + ) + return ( + copy.intro ? ( + ) : undefined } > @@ -40,8 +56,8 @@ export default function ActivistBuilderPage() { afform={AFFORM} afformOptions={AFFORM_OPTIONS} apiEndpoint={getAfformSubmitApiUrl()} - pagePrivacy={AFFORM_PAGE_PRIVACY} - pagePrivacyLink={AFFORM_PAGE_PRIVACY_LINK} + pagePrivacy={copy.privacy} + pagePrivacyLink={copy.privacyLink} extraPayload={{ formName: AFFORM_NAME }} /> diff --git a/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx b/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx new file mode 100644 index 0000000000..a28fe7a8f9 --- /dev/null +++ b/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { ActivistLeaderStewardCopySection } from '@repo/content/schemas' + +const sectionData: ActivistLeaderStewardCopySection = { + componentType: 'activistLeaderStewardCopy', + key: 'activistLeaderSteward.copy', + heading: 'Activist Leader / Steward', + intro: 'Activist Leader / Steward intro.', + privacy: 'Activist Leader / Steward privacy.', + privacyLink: 'https://logos.co/privacy-policy', +} + +describe('ActivistLeaderStewardPage – content-driven metadata', () => { + it('section data has the correct componentType', () => { + expect(sectionData.componentType).toBe('activistLeaderStewardCopy') + }) + + it('section data has the correct heading', () => { + expect(sectionData.heading).toBe('Activist Leader / Steward') + }) + + it('renders heading in a simple element', () => { + const html = renderToStaticMarkup( + createElement('h1', null, sectionData.heading) + ) + expect(html).toContain('Activist Leader / Steward') + expect(html).toContain(' { + expect(sectionData.intro).toBe('Activist Leader / Steward intro.') + expect(sectionData.privacy).toBe('Activist Leader / Steward privacy.') + expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy') + }) +}) diff --git a/apps/web/app/[locale]/activist-leader-steward/page.tsx b/apps/web/app/[locale]/activist-leader-steward/page.tsx index 49cbadd073..63a6976722 100644 --- a/apps/web/app/[locale]/activist-leader-steward/page.tsx +++ b/apps/web/app/[locale]/activist-leader-steward/page.tsx @@ -1,3 +1,7 @@ +import { getPageCopy } from '@repo/content/loaders' +import { isActiveLocale } from '@repo/content/locales' +import type { ActivistLeaderStewardCopySection } from '@repo/content/schemas' + import { AfformPageIntro, ConnectFormSection, @@ -9,30 +13,44 @@ import { AFFORM, AFFORM_NAME, AFFORM_OPTIONS, - AFFORM_PAGE_INTRO, - AFFORM_PAGE_PRIVACY, - AFFORM_PAGE_PRIVACY_LINK, } from '@/lib/civicrm/afform-activist-leader-steward' -import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata' +import { createPageMetadata } from '@/lib/page-metadata' +import { createSectionFinder } from '@/lib/page-sections' -const NAMESPACE = 'pages.activistLeaderSteward' +const ROUTE = ROUTES.activistLeaderSteward +const findSection = createSectionFinder('activist-leader-steward') -export const generateMetadata = createTranslatedPageMetadata({ - namespace: NAMESPACE, - path: ROUTES.activistLeaderSteward, -}) +export const generateMetadata = createPageMetadata(ROUTE) function getAfformSubmitApiUrl() { const base = env.NEXT_PUBLIC_CIVI_CRM_URL return base ? `${base.replace(/\/+$/, '')}/api/public/afform-submit` : '' } -export default function ActivistLeaderStewardPage() { +export default async function ActivistLeaderStewardPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + if (!isActiveLocale(locale)) { + throw new Error( + `ActivistLeaderStewardPage received non-active locale "${locale}"` + ) + } + + const page = await getPageCopy(ROUTE, locale) + const copy = findSection( + page.sections, + 'activistLeaderStewardCopy', + 'activistLeaderSteward.copy' + ) + return ( + copy.intro ? ( + ) : undefined } > @@ -40,8 +58,8 @@ export default function ActivistLeaderStewardPage() { afform={AFFORM} afformOptions={AFFORM_OPTIONS} apiEndpoint={getAfformSubmitApiUrl()} - pagePrivacy={AFFORM_PAGE_PRIVACY} - pagePrivacyLink={AFFORM_PAGE_PRIVACY_LINK} + pagePrivacy={copy.privacy} + pagePrivacyLink={copy.privacyLink} extraPayload={{ formName: AFFORM_NAME }} /> diff --git a/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx b/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx new file mode 100644 index 0000000000..bd535819a1 --- /dev/null +++ b/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx @@ -0,0 +1,34 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +// Stub next/image to avoid Next.js server-only dependencies +vi.mock('next/image', () => ({ + default: ({ alt, src }: { alt: string; src: string }) => + createElement('img', { alt, src }), +})) + +// Stub ActionGroup to avoid icon/constant dependencies +vi.mock('../atoms', () => ({ + ActionGroup: ({ className }: { className?: string }) => + createElement('div', { className }, 'actions'), +})) + +import { Hero } from '../hero' + +describe('Hero – heading driven from data', () => { + it('renders the heading prop in the h1', () => { + const html = renderToStaticMarkup( + createElement(Hero, { heading: 'Farewell to Westphalia' }) + ) + expect(html).toContain('Farewell to Westphalia') + expect(html).toContain(' { + const html = renderToStaticMarkup( + createElement(Hero, { heading: 'Custom Heading' }) + ) + expect(html).toContain('Custom Heading') + }) +}) diff --git a/apps/web/app/[locale]/book/_sections/book-page.tsx b/apps/web/app/[locale]/book/_sections/book-page.tsx index 39415e23da..67045fae23 100644 --- a/apps/web/app/[locale]/book/_sections/book-page.tsx +++ b/apps/web/app/[locale]/book/_sections/book-page.tsx @@ -1,3 +1,5 @@ +import type { BookCopySection } from '@repo/content/schemas' + import { Authors } from './authors' import { Hero } from './hero' import { Overview } from './overview' @@ -5,10 +7,14 @@ import { ReadTheBook } from './read-the-book' import { Translations } from './translations' import { Video } from './video' -export function BookPage() { +interface BookPageProps { + data: BookCopySection +} + +export function BookPage({ data }: BookPageProps) { return (
- +