refactor: make @repo/content the single source for home, get-started & movement page copy (#62)

* docs: add page-copy single-source spec and implementation plan

* feat(content): add typed home section schemas and migrate home copy into content

Add 7 bespoke home* section schemas (+ techStack ctas) to the page section
union and populate content/pages/en/home.json with home copy moved verbatim
from messages.

* refactor(web): source home page copy from @repo/content; drop home namespace from messages

Feed every home section from content via findSection/data props (replacing
next-intl getTranslations), remove the home namespace from messages/en.json,
and add a regression guard plus content-based tests. Render output unchanged.

* docs: add get-started/movement page-copy dedup spec and plan

* feat(content): add get-started & movement section schemas and content docs

Move get-started/movement page copy verbatim into content/pages so it is
single-sourced in @repo/content.

* refactor(web): source get-started/movement copy from content; drop those namespaces from messages

Feed both pages from content via findSection/data props (replacing next-intl),
switch them to content-based metadata, delete pages.getStarted/movement from
messages, and add content-based tests + ownership guard. Render unchanged.

* docs: add navbar-pages CMS copy spec and plan

* feat(content): add navbar page section schemas + Pages docs (book, brand-kit, research, node-programme, lambda-prize, manifesto, media, podcast, broadcast, tech-stack explorer)

* refactor(web): source navbar pages copy from content; drop those namespaces from messages

Migrate book, brand-kit, research (incl. rich-text via a tagged-text renderer),
node-programme, lambda-prize, manifesto, media, podcast, logos-broadcast-network,
and the tech-stack explorer to read copy from content via findSection/data props
and content-based metadata; remove the migrated pages.* namespaces from messages;
update guards + contracts. Render output unchanged.

* feat(web): source activist/coalition pages copy from content (CMS title/description)

Migrate activistBuilder, activistLeaderSteward, and coalitionPartner page
metadata from next-intl messages.pages.* into @repo/content PageCopy JSON,
following the established bookCopy pattern.

- Add activistBuilderCopySectionSchema, activistLeaderStewardCopySectionSchema,
  coalitionPartnerCopySectionSchema to packages/content/src/schemas/pages.ts
  and wire them into the pageSectionSchema discriminated union
- Create content/pages/en/activist-builder.json,
  activist-leader-steward.json, coalition-partner.json with verbatim
  title/description/heading values from the old messages namespace
- Switch all three route pages from createTranslatedPageMetadata to
  createPageMetadata(ROUTE), removing the NAMESPACE constant
- Remove activistBuilder, activistLeaderSteward, coalitionPartner from
  apps/web/messages/en.json pages object
- Add schema parse tests, renderToStaticMarkup page tests, messages ownership
  guards, and content-route contract entries for all three pages

* chore(web): remove dead faq (pages.faq + unused FaqSection components)

* fix: remove stale faq links

* fix: pass payload env through turbo build

* fix: preserve lambda prize support labels

* fix: preserve live copy after master rebase

* fix: address page copy review feedback

* refactor: move design guide copy to content

* feat: add schemas for home and page copy sections

- Introduced home section schemas including homeSocialProof, homeChoosePath, homeDecide, homeStartBuilding, homeAbout, homeUseCases, and homeBuilderPortal.
- Added page copy section schemas such as getStartedCopy, movementCopy, bookCopy, designGuideCopy, activistBuilderCopy, activistLeaderStewardCopy, coalitionPartnerCopy, researchCopy, nodeProgrammeCopy, lambdaPrizeCopy, mediaCopy, podcastCopy, broadcastCopy, and manifestoCopy.
- Created shared schema for section keys to maintain consistency across sections.

* chore(web): remove dead pages.faq from messages

* fix: resolve page copy migration conflicts

* fix: source connect form page copy from content

* fix: remove duplicated afform page copy exports

* fix: stop generating afform page copy exports

* fix(cms): require only server actions key

* fix(cms): load env files before build validation

* fix(cms): make env loader available during build

* fix(cms): load next env via require

* test(cms): isolate missing env validation

* feat: add homepage testnet highlight

* fix: restore lambda prize support links
This commit is contained in:
Jinho Jang
2026-07-06 19:09:02 +09:00
committed by GitHub
parent 2178a15473
commit 96be50d8d0
128 changed files with 6098 additions and 2627 deletions
Vendored
-1
View File
@@ -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 ."
)
}
+1 -3
View File
@@ -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.
-3
View File
@@ -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} \
+1 -5
View File
@@ -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='<stable openssl rand -base64 32 value
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.logos.co \
--build-arg NEXT_PUBLIC_WEB_URL=https://logos.co \
-t logos-cms .
@@ -137,7 +135,6 @@ docker run -d --name logos-cms -p 3001:3000 \
| Variable | When it's read | Notes |
| --- | --- | --- |
| `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` | **Build time** | Stable key required by Next Server Actions across CMS deployments. |
| `DEPLOYMENT_VERSION` | **Build time** | Used as Next's deployment id to detect old/new deployment skew. |
| `NEXT_PUBLIC_SERVER_URL`, `NEXT_PUBLIC_WEB_URL` | **Build time** (inlined into client bundle) | Must be real public origins; changing requires a rebuild. |
| `DATABASE_URL`, `PAYLOAD_SECRET` | **Runtime** | Never baked into the image. |
| `PAYLOAD_DB_SCHEMA`, `PAYLOAD_DB_POOL_MAX` | Runtime | Defaults: `payload`, `10`. |
@@ -145,8 +142,7 @@ docker run -d --name logos-cms -p 3001:3000 \
| `GITHUB_*` | Runtime | Optional — only for the Admin "Create PR" action. |
For Vercel CMS deployments, set `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` as a
project environment variable for production and preview builds. Vercel provides
`VERCEL_GIT_COMMIT_SHA`, which the CMS uses as the deployment id.
project environment variable for production and preview builds.
### Database migrations
-6
View File
@@ -8,13 +8,7 @@ import { withPayload } from '@payloadcms/next/withPayload'
// build time.
const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url))
const deploymentId =
process.env.NEXT_DEPLOYMENT_ID ||
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.DEPLOYMENT_VERSION
const nextConfig = {
...(deploymentId ? { deploymentId } : {}),
reactStrictMode: true,
outputFileTracingRoot: workspaceRoot,
turbopack: {
+1
View File
@@ -20,6 +20,7 @@
"sync-from-content": "tsx scripts/sync-from-content.ts"
},
"dependencies": {
"@next/env": "16.2.4",
"@payloadcms/db-postgres": "3.83.0",
"@payloadcms/next": "3.83.0",
"@payloadcms/richtext-lexical": "3.83.0",
@@ -1,24 +1,25 @@
import { createRequire } from 'node:module'
interface NextEnv {
loadEnvConfig: (dir: string) => 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)
}
@@ -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<string, string | undefined>
@@ -52,7 +60,8 @@ const importNextConfig = async (
}
const runBuildEnvValidation = async (
overrides: Record<string, string | undefined>
overrides: Record<string, string | undefined>,
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)),
[]
)
})
@@ -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()
@@ -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('<h1')
})
it('has CMS-backed form page copy', () => {
expect(sectionData.intro).toBe('Activist Builder intro.')
expect(sectionData.privacy).toBe('Activist Builder privacy.')
expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy')
})
})
+30 -14
View File
@@ -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<ActivistBuilderCopySection>(
page.sections,
'activistBuilderCopy',
'activistBuilder.copy'
)
return (
<ConnectPageLayout
intro={
AFFORM_PAGE_INTRO ? (
<AfformPageIntro text={AFFORM_PAGE_INTRO} />
copy.intro ? (
<AfformPageIntro text={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 }}
/>
</ConnectPageLayout>
@@ -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('<h1')
})
it('has CMS-backed form page copy', () => {
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')
})
})
@@ -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<ActivistLeaderStewardCopySection>(
page.sections,
'activistLeaderStewardCopy',
'activistLeaderSteward.copy'
)
return (
<ConnectPageLayout
intro={
AFFORM_PAGE_INTRO ? (
<AfformPageIntro text={AFFORM_PAGE_INTRO} />
copy.intro ? (
<AfformPageIntro text={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 }}
/>
</ConnectPageLayout>
@@ -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('<h1')
})
it('renders an arbitrary heading value', () => {
const html = renderToStaticMarkup(
createElement(Hero, { heading: 'Custom Heading' })
)
expect(html).toContain('Custom Heading')
})
})
@@ -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 (
<main className="bg-brand-off-white text-brand-dark-green">
<Hero />
<Hero heading={data.heading} />
<Overview />
<Authors />
<Video />
@@ -2,7 +2,11 @@ import Image from 'next/image'
import { ActionGroup } from './atoms'
export function Hero() {
interface HeroProps {
heading: string
}
export function Hero({ heading }: HeroProps) {
return (
<section className="relative min-h-screen overflow-hidden pt-10">
<Image
@@ -16,7 +20,7 @@ export function Hero() {
<div className="absolute inset-0 bg-brand-dark-green/35" />
<div className="relative z-10 flex min-h-[calc(100vh-40px)] flex-col items-center justify-end px-3 pb-16 text-center text-brand-off-white">
<h1 className="max-w-5xl font-display text-[56px] leading-none tracking-[-0.03em] md:text-[104px]">
Farewell to Westphalia
{heading}
</h1>
<p className="mt-6 max-w-2xl font-sans text-[18px] leading-[1.2] md:text-[22px]">
A book co-authored by Logos co-founder Jarrad Hope and Peter Ludlow
+24 -13
View File
@@ -1,25 +1,36 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { BookCopySection } from '@repo/content/schemas'
import { ROUTES } from '@/constants/routes'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { BookPage } from './_sections/book-page'
export async function generateMetadata({
const ROUTE = ROUTES.book
const findSection = createSectionFinder('book')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function Page({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.book' })
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.book,
})
}
if (!isActiveLocale(locale)) {
throw new Error(`BookPage received non-active locale "${locale}"`)
}
export default async function Page() {
return <BookPage />
const page = await getPageCopy(ROUTE, locale)
const bookCopy = findSection<BookCopySection>(
page.sections,
'bookCopy',
'book.copy',
)
return <BookPage data={bookCopy} />
}
@@ -0,0 +1,37 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { CoalitionPartnerCopySection } from '@repo/content/schemas'
const sectionData: CoalitionPartnerCopySection = {
componentType: 'coalitionPartnerCopy',
key: 'coalitionPartner.copy',
heading: 'Coalition Partner',
intro: 'Coalition Partner intro.',
privacy: 'Coalition Partner privacy.',
privacyLink: 'https://logos.co/privacy-policy',
}
describe('CoalitionPartnerPage content-driven metadata', () => {
it('section data has the correct componentType', () => {
expect(sectionData.componentType).toBe('coalitionPartnerCopy')
})
it('section data has the correct heading', () => {
expect(sectionData.heading).toBe('Coalition Partner')
})
it('renders heading in a simple element', () => {
const html = renderToStaticMarkup(
createElement('h1', null, sectionData.heading)
)
expect(html).toContain('Coalition Partner')
expect(html).toContain('<h1')
})
it('has CMS-backed form page copy', () => {
expect(sectionData.intro).toBe('Coalition Partner intro.')
expect(sectionData.privacy).toBe('Coalition Partner privacy.')
expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy')
})
})
@@ -1,3 +1,7 @@
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { CoalitionPartnerCopySection } 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-coalition-partner'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
const NAMESPACE = 'pages.coalitionPartner'
const ROUTE = ROUTES.coalitionPartner
const findSection = createSectionFinder('coalition-partner')
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.coalitionPartner,
})
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 CoalitionPartnerPage() {
export default async function CoalitionPartnerPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
if (!isActiveLocale(locale)) {
throw new Error(`CoalitionPartnerPage received non-active locale "${locale}"`)
}
const page = await getPageCopy(ROUTE, locale)
const copy = findSection<CoalitionPartnerCopySection>(
page.sections,
'coalitionPartnerCopy',
'coalitionPartner.copy'
)
return (
<ConnectPageLayout
intro={
AFFORM_PAGE_INTRO ? (
<AfformPageIntro text={AFFORM_PAGE_INTRO} />
copy.intro ? (
<AfformPageIntro text={copy.intro} />
) : undefined
}
>
@@ -40,8 +56,8 @@ export default function CoalitionPartnerPage() {
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 }}
/>
</ConnectPageLayout>
+25 -15
View File
@@ -1,15 +1,16 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { DesignGuideCopySection } from '@repo/content/schemas'
import { DocsPageShell } from '@/components/sections/shared/docs-page-shell'
import { ROUTES } from '@/constants/routes'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
const NAMESPACE = 'pages.designGuide'
const ROUTE = ROUTES.designGuide
const findSection = createSectionFinder('design-guide')
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.designGuide,
})
export const generateMetadata = createPageMetadata(ROUTE)
export default async function DesignGuidePage({
params,
@@ -17,34 +18,43 @@ export default async function DesignGuidePage({
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: NAMESPACE })
if (!isActiveLocale(locale)) {
throw new Error(`DesignGuidePage received non-active locale "${locale}"`)
}
const page = await getPageCopy(ROUTE, locale)
const copy = findSection<DesignGuideCopySection>(
page.sections,
'designGuideCopy',
'designGuide.copy'
)
return (
<DocsPageShell activeKey="designGuide">
<h1 className="text-eyebrow w-full text-brand-dark-green">
{t('heading')}
{copy.heading}
</h1>
<p className="text-mono-s w-full text-brand-dark-green">{t('intro')}</p>
<p className="text-mono-s w-full text-brand-dark-green">{copy.intro}</p>
<a
href={t('downloads.brandMarksHref')}
href={copy.downloads.brandMarks.href}
download
className="text-eyebrow inline-flex w-fit cursor-pointer items-center gap-1 rounded-xl bg-gray-01 px-3 py-3 text-brand-dark-green"
>
{t('downloads.brandMarksLabel')}
{copy.downloads.brandMarks.label}
</a>
<p className="text-eyebrow w-full pt-3 text-brand-dark-green">
{t('downloads.guidelinesSection')}
{copy.downloads.guidelinesSection}
</p>
<a
href={t('downloads.guidelinesHref')}
href={copy.downloads.guidelines.href}
download
className="text-eyebrow inline-flex w-fit cursor-pointer items-center gap-1 rounded-xl bg-gray-01 px-3 py-3 text-brand-dark-green"
>
{t('downloads.guidelinesLabel')}
{copy.downloads.guidelines.label}
</a>
</DocsPageShell>
)
@@ -0,0 +1,169 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { GetStartedCopySection } from '@repo/content/schemas'
// Stub heavy imports to avoid browser-only / server-only dependencies
vi.mock('@acid-info/logos-ui', () => ({
LogosMark: () => createElement('span', null, 'LogosMark'),
}))
vi.mock('@/components/layout/content-width', () => ({
default: ({ children, className }: { children: ReactNode; className?: string }) =>
createElement('div', { className }, children),
}))
vi.mock('@/components/sections/shared/basecamp-install-section', () => ({
BasecampInstallSection: ({
title,
index,
}: {
title: string
index: string
id?: string
cards?: unknown[]
}) => createElement('div', null, index, title),
}))
vi.mock('@/components/sections/shared/start-building-card-grid', () => ({
StartBuildingCardGrid: ({ cards }: { cards: { title: string }[] }) =>
createElement(
'ul',
null,
...cards.map((c, i) => createElement('li', { key: i }, c.title))
),
}))
vi.mock('@/components/sections/shared/developer-programs-section', () => ({
DeveloperProgramsSection: () => createElement('div', null, 'Programs'),
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children: ReactNode }) =>
createElement('span', null, children),
ButtonArrowIcon: () => createElement('span', null, '→'),
}))
import { GetStartedPage } from '../get-started-page'
const sectionData: GetStartedCopySection = {
componentType: 'getStartedCopy',
key: 'getStarted.copy',
heading: 'Get Started',
intro: 'Everything you need to start building privacy-first, decentralised applications.',
sections: {
install: {
number: '01',
heading: 'Install Basecamp',
cardTitle: 'Install Logos Basecamp',
body: 'Basecamp is the development environment for Logos.',
cta: 'Install Basecamp',
imageAlt: 'Logos Basecamp application interface',
},
docs: {
number: '02',
heading: 'Start Building',
items: {
docs: { title: 'Read the docs', body: 'Deep dive into the Logos stack.' },
scaffold: { title: 'Start with the Scaffold Boilerplate', body: 'A Rust CLI.' },
sampleApps: { title: 'Sample Apps', body: 'Run real sample apps.' },
workshops: { title: 'Workshops and Tutorials', body: 'Live coding sessions.' },
},
viewDocsCta: 'View the docs',
learnMoreCta: 'Learn more',
atomicSwapsCta: 'Atomic swaps',
multisigCta: 'Multisig',
},
community: {
number: '04',
heading: 'Join the community',
cta: 'Learn more',
items: {
forum: 'Community Forum',
research: 'Research Forum',
discord: 'Discord',
xLogosNetwork: 'X Logos_network',
xLogosDevs: 'X Logos_devs',
youtubeTutorials: 'YouTube Tutorials',
},
},
build: {
number: '04',
heading: 'What you can build today',
cta: 'Run app',
nodeCta: 'Run a node',
messagingCta: 'Messaging SDK',
deployCta: 'Deploy',
tryItOutCta: 'Try it out',
scaffoldCta: 'Run scaffold',
items: {
node: { title: 'Run a node', body: 'Participate in the testnet.' },
messaging: { title: 'Build with Logos Messaging SDK', body: 'Add private messaging.' },
lez: { title: 'Deploy to the LEZ', body: 'The Logos Execution Zone.' },
storage: { title: 'Store files on Logos Storage', body: 'Use decentralised storage.' },
atomicSwaps: { title: 'Run Atomic Swaps Demo App', body: '' },
zkMultiSig: { title: 'Run ZK Multi-Sig Demo App', body: '' },
scaffold: { title: 'Logos Scaffold', body: '' },
},
},
},
}
const basecampInstall = {
cards: [],
}
const developerPrograms = {
title: 'Programs',
items: [],
}
describe('GetStartedPage content-driven copy', () => {
it('renders heading from data prop', () => {
const html = renderToStaticMarkup(
createElement(GetStartedPage, {
data: sectionData,
basecampInstall: basecampInstall as never,
developerPrograms: developerPrograms as never,
rfps: [],
})
)
expect(html).toContain('Get Started')
})
it('renders intro from data prop', () => {
const html = renderToStaticMarkup(
createElement(GetStartedPage, {
data: sectionData,
basecampInstall: basecampInstall as never,
developerPrograms: developerPrograms as never,
rfps: [],
})
)
expect(html).toContain('Everything you need to start building privacy-first')
})
it('renders docs heading "Start Building" from data', () => {
const html = renderToStaticMarkup(
createElement(GetStartedPage, {
data: sectionData,
basecampInstall: basecampInstall as never,
developerPrograms: developerPrograms as never,
rfps: [],
})
)
expect(html).toContain('Start Building')
})
it('renders community heading from data', () => {
const html = renderToStaticMarkup(
createElement(GetStartedPage, {
data: sectionData,
basecampInstall: basecampInstall as never,
developerPrograms: developerPrograms as never,
rfps: [],
})
)
expect(html).toContain('Join the community')
})
})
@@ -1,8 +1,10 @@
import type { GetStartedCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { ActionLink, SectionHeading } from './atoms'
import type { BuildItem, GetStartedTranslator } from './types'
import type { BuildItem } from './types'
const buildItems: readonly BuildItem[] = [
{ key: 'node', ctaKey: 'nodeCta', href: ROUTES.nodeProgramme, hasBody: true },
@@ -24,13 +26,13 @@ const buildItems: readonly BuildItem[] = [
{ key: 'scaffold', ctaKey: 'scaffoldCta', href: EXTERNAL_URLS.github },
] as const
export function Build({ t }: { t: GetStartedTranslator }) {
export function Build({ data }: { data: GetStartedCopySection['sections']['build'] }) {
return (
<section className="border-t border-brand-dark-green/10 pt-6 pb-25">
<ContentWidth className="flex w-full flex-col gap-10">
<SectionHeading
number={t('sections.build.number')}
heading={t('sections.build.heading')}
number={data.number}
heading={data.heading}
/>
<div className="grid gap-3 md:grid-cols-3">
{buildItems.map((item) => {
@@ -41,11 +43,11 @@ export function Build({ t }: { t: GetStartedTranslator }) {
>
<div className="flex flex-col gap-3">
<h3 className="font-sans text-[24px] leading-[1.1] tracking-[-0.24px]">
{t(`sections.build.items.${item.key}.title`)}
{data.items[item.key].title}
</h3>
{'hasBody' in item && item.hasBody ? (
<p className="max-w-[329px] font-sans text-[14px] leading-[1.2]">
{t(`sections.build.items.${item.key}.body`)}
{data.items[item.key].body ?? ''}
</p>
) : null}
</div>
@@ -54,7 +56,7 @@ export function Build({ t }: { t: GetStartedTranslator }) {
variant="primary"
className="w-fit self-start"
>
{t(`sections.build.${item.ctaKey}`)}
{data[item.ctaKey]}
</ActionLink>
</article>
)
@@ -1,9 +1,11 @@
import type { GetStartedCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { ButtonArrowIcon } from '@/components/ui/button'
import { EXTERNAL_URLS } from '@/constants/routes'
import { SectionHeading } from './atoms'
import type { CommunityLink, GetStartedTranslator } from './types'
import type { CommunityLink } from './types'
const communityLinks: readonly CommunityLink[] = [
{ key: 'forum', href: EXTERNAL_URLS.forum },
@@ -14,13 +16,17 @@ const communityLinks: readonly CommunityLink[] = [
{ key: 'youtubeTutorials', href: EXTERNAL_URLS.youtube },
] as const
export function Community({ t }: { t: GetStartedTranslator }) {
export function Community({
data,
}: {
data: GetStartedCopySection['sections']['community']
}) {
return (
<section className="border-t border-brand-dark-green/10 pt-6 pb-25">
<ContentWidth className="flex w-full flex-col gap-10">
<SectionHeading
number={t('sections.community.number')}
heading={t('sections.community.heading')}
number={data.number}
heading={data.heading}
/>
<div className="flex flex-col">
{communityLinks.map((item, index) => (
@@ -38,11 +44,11 @@ export function Community({ t }: { t: GetStartedTranslator }) {
{String(index + 1).padStart(2, '0')}
</span>
<span className="font-display">
{t(`sections.community.items.${item.key}`)}
{data.items[item.key]}
</span>
</span>
<span className="inline-flex items-center gap-1 border-b border-brand-dark-green/50 pb-0.5 font-mono text-[10px] leading-[1.35] font-semibold whitespace-nowrap uppercase">
{t('sections.community.cta')}
{data.cta}
<ButtonArrowIcon />
</span>
</a>
@@ -1,3 +1,5 @@
import type { GetStartedCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import {
StartBuildingCardGrid,
@@ -6,37 +8,36 @@ import {
import { EXTERNAL_URLS } from '@/constants/routes'
import { SectionHeading } from './atoms'
import type { GetStartedTranslator } from './types'
const cards = [
{
key: 'docs',
ctas: [{ labelKey: 'viewDocsCta', href: EXTERNAL_URLS.docs }],
key: 'docs' as const,
ctas: [{ labelKey: 'viewDocsCta' as const, href: EXTERNAL_URLS.docs }],
},
{
key: 'scaffold',
ctas: [{ labelKey: 'learnMoreCta', href: EXTERNAL_URLS.scaffold }],
key: 'scaffold' as const,
ctas: [{ labelKey: 'learnMoreCta' as const, href: EXTERNAL_URLS.scaffold }],
},
{
key: 'sampleApps',
key: 'sampleApps' as const,
ctas: [
{ labelKey: 'atomicSwapsCta', href: EXTERNAL_URLS.atomicSwaps },
{ labelKey: 'multisigCta', href: EXTERNAL_URLS.multisig },
{ labelKey: 'atomicSwapsCta' as const, href: EXTERNAL_URLS.atomicSwaps },
{ labelKey: 'multisigCta' as const, href: EXTERNAL_URLS.multisig },
],
},
{
key: 'workshops',
ctas: [{ labelKey: 'learnMoreCta', href: EXTERNAL_URLS.workshopsPlaylist }],
key: 'workshops' as const,
ctas: [{ labelKey: 'learnMoreCta' as const, href: EXTERNAL_URLS.workshopsPlaylist }],
},
] as const
]
export function Docs({ t }: { t: GetStartedTranslator }) {
export function Docs({ data }: { data: GetStartedCopySection['sections']['docs'] }) {
const startBuildingCards: readonly StartBuildingCard[] = cards.map(
(card) => ({
title: t(`sections.docs.items.${card.key}.title`),
description: t(`sections.docs.items.${card.key}.body`),
title: data.items[card.key].title,
description: data.items[card.key].body ?? '',
ctas: card.ctas.map((cta) => ({
label: t(`sections.docs.${cta.labelKey}`),
label: data[cta.labelKey],
href: cta.href,
external: true,
})),
@@ -47,8 +48,8 @@ export function Docs({ t }: { t: GetStartedTranslator }) {
<section className="border-t border-brand-dark-green/10 pt-6 pb-25 md:pt-10">
<ContentWidth className="flex w-full flex-col gap-10">
<SectionHeading
number={t('sections.docs.number')}
heading={t('sections.docs.heading')}
number={data.number}
heading={data.heading}
/>
<StartBuildingCardGrid cards={startBuildingCards} />
@@ -1,5 +1,5 @@
import type { BuilderHubHomeRfpResolution } from '@repo/content/loaders'
import type { BuilderHubSettings } from '@repo/content/schemas'
import type { BuilderHubSettings, GetStartedCopySection } from '@repo/content/schemas'
// import { Build } from './build' // "What you can build today" section hidden — kept for future use
import { Community } from './community'
@@ -7,28 +7,27 @@ import { Docs } from './docs'
import { Hero } from './hero'
import { Install } from './install'
import { Programs } from './programs'
import type { GetStartedTranslator } from './types'
export function GetStartedPage({
t,
data,
basecampInstall,
developerPrograms,
rfps,
}: {
t: GetStartedTranslator
data: GetStartedCopySection
basecampInstall: NonNullable<BuilderHubSettings['prepare']>
developerPrograms: NonNullable<BuilderHubSettings['programs']>
rfps: BuilderHubHomeRfpResolution['rfps']
}) {
return (
<div className="bg-brand-off-white text-brand-dark-green">
<Hero heading={t('heading')} intro={t('intro')} />
<Install t={t} data={basecampInstall} />
<Docs t={t} />
<Hero heading={data.heading} intro={data.intro} />
<Install labels={data.sections.install} data={basecampInstall} />
<Docs data={data.sections.docs} />
<Programs data={developerPrograms} rfps={rfps} />
<Community t={t} />
<Community data={data.sections.community} />
{/* "What you can build today" section hidden — component kept for future use */}
{/* <Build t={t} /> */}
{/* <Build data={data.sections.build} /> */}
</div>
)
}
@@ -1,21 +1,19 @@
import type { BuilderHubSettings } from '@repo/content/schemas'
import type { BuilderHubSettings, GetStartedCopySection } from '@repo/content/schemas'
import { BasecampInstallSection } from '@/components/sections/shared/basecamp-install-section'
import type { GetStartedTranslator } from './types'
export function Install({
t,
labels,
data,
}: {
t: GetStartedTranslator
labels: GetStartedCopySection['sections']['install']
data: NonNullable<BuilderHubSettings['prepare']>
}) {
return (
<BasecampInstallSection
id="install"
index={t('sections.install.number')}
title={t('sections.install.heading')}
index={labels.number}
title={labels.heading}
cards={data.cards}
/>
)
@@ -1,16 +1,6 @@
/**
* Shared types for the Get Started page sections. The page-level translator is
* passed to each section so the section JSX can read its own copy slice via the
* same `t(...)` keys it used when the page was a single file.
* Shared types for the Get Started page sections.
*/
import type { getTranslations } from 'next-intl/server'
export type GetStartedTranslator = Awaited<ReturnType<typeof getTranslations>>
export interface DocItem {
key: string
href: string
}
export interface CommunityLink {
key: string
+17 -10
View File
@@ -1,21 +1,22 @@
import {
getBuilderHubSettings,
getPageCopy,
resolveBuilderHubHomeRfps,
} from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import { getTranslations } from 'next-intl/server'
import type { GetStartedCopySection } from '@repo/content/schemas'
import { ROUTES } from '@/constants/routes'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { GetStartedPage } from './_sections/get-started-page'
const NAMESPACE = 'pages.getStarted'
const ROUTE = ROUTES.getStarted
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.getStarted,
})
const findSection = createSectionFinder('get-started')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function Page({
params,
@@ -27,12 +28,18 @@ export default async function Page({
throw new Error(`GetStartedPage received non-active locale "${locale}"`)
}
const [t, builderHubSettings, rfpResolution] = await Promise.all([
getTranslations({ locale, namespace: NAMESPACE }),
const [page, builderHubSettings, rfpResolution] = await Promise.all([
getPageCopy(ROUTE, locale),
getBuilderHubSettings(locale),
resolveBuilderHubHomeRfps(locale),
])
const copySection = findSection<GetStartedCopySection>(
page.sections,
'getStartedCopy',
'getStarted.copy'
)
if (!builderHubSettings.prepare) {
throw new Error('GetStartedPage requires Builders Hub prepare settings')
}
@@ -42,7 +49,7 @@ export default async function Page({
return (
<GetStartedPage
t={t}
data={copySection}
basecampInstall={builderHubSettings.prepare}
developerPrograms={builderHubSettings.programs}
rfps={rfpResolution.rfps}
@@ -0,0 +1,206 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { LambdaPrizeCopySection } from '@repo/content/schemas'
import lambdaPrizePage from '../../../../../../content/pages/en/lambda-prize.json' with { type: 'json' }
vi.mock('@/components/layout/content-width', () => ({
default: ({ children }: { children: React.ReactNode }) => createElement('div', null, children),
}))
vi.mock('@/components/ui', () => ({
ButtonArrowIcon: () => createElement('span', null, 'arrow'),
}))
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) =>
createElement('a', { href, className }, children),
}))
import { Support } from '../_sections/support'
const sectionData: LambdaPrizeCopySection = {
componentType: 'lambdaPrizeCopy',
key: 'lambdaPrize.copy',
hero: {
label: 'Prize',
heading: 'The frontier is open.\nBuild what\'s next.',
body: 'Prize is the competitive prize framework powering the Logos network. A programme to accelerate real, shipped applications built on the Logos stack.',
primaryCta: 'View Prizes',
secondaryCta: 'Builder Hub',
},
howItWorks: {
heading: 'How It Works',
rows: [
{ label: '01', body: 'Prizes are broken into three categories - small, medium, and large.' },
{ label: '02', body: 'All prizes are listed in the GitHub repository.' },
{ label: '03', body: 'Submissions are made as pull requests to the repository.' },
{ label: '04', body: 'Prizes will remain open until they are claimed or explicitly closed.' },
{ label: '05', body: 'Proposals are evaluated based on specific criteria outlined for each prize.' },
{ label: '06', body: 'Winners will be notified directly, after which rewards will be issued.' },
],
},
evaluation: {
heading: 'Evaluation Criteria',
primaryCta: 'View Prizes',
secondaryCta: 'Connect with the team on Discord',
rows: [
{ label: 'Step 1', body: 'Pre-Screen Checklist: Submissions are reviewed by Logos core contributors to ensure baseline criteria are met.' },
{ label: 'Step 2', body: 'Technical Evaluation: Logos core contributors will clone submission repositories to a clean environment and run a demo script without modification to verify success criteria and spot-check code quality and architecture.' },
],
},
featured: {
heading: 'Featured Prizes',
status: 'Open',
prizes: [
{
meta: { id: 'LP-0017', effort: 'Effort: Medium', prize: 'Prize: $400' },
title: 'Whistleblower — censorship-resistant document upload and indexing Basecamp app',
body: 'This prize funds "Whistleblower", a Logos Basecamp app.',
url: 'https://github.com/logos-co/lambda-prize/blob/master/prizes/LP-0017.md',
},
],
},
about: {
heading: 'About the Programme',
body: 'The systems that were supposed to protect us, the institutions, the corporations, the platforms, have been captured.',
primaryCta: 'View Prizes',
secondaryCta: 'Start Building',
rows: [
{ label: 'Real outcomes, not proposals', body: 'Prizes go to shipped code that meets defined criteria.' },
{ label: 'A stack that is private by default', body: 'Privacy is a requirement for sovereignty.' },
{ label: 'Builders get support on their journey', body: 'Regular office hours, livestreams, and direct access to the ecosystem development engineering team.' },
],
},
techStack: {
startBuildingCta: 'Start Building',
docsCta: 'View the docs',
},
support: {
heading: 'Get Support',
body: 'Find examples, tutorials, and community channels for building on Logos.',
cta: 'Help Center',
rows: [
{ label: 'Sample Apps', body: 'Explore reference applications that demonstrate common Logos workflows.', action: 'View' },
{ label: 'Demos and Tutorials', body: 'Follow practical walkthroughs for core stack concepts and integrations.', action: 'View' },
{ label: 'Install Basecamp', body: 'Set up Basecamp and start experimenting with bundled modules.', action: 'Install' },
{ label: 'Connect with Logos', body: 'Join the community channels for implementation support and feedback.', action: 'Connect' },
],
},
}
function HeroContent({ data }: { data: LambdaPrizeCopySection }) {
return createElement(
'div',
null,
createElement('h1', null, data.hero.heading),
createElement('p', null, data.hero.body),
createElement('span', null, data.hero.label),
)
}
function HowItWorksContent({ data }: { data: LambdaPrizeCopySection }) {
return createElement(
'div',
null,
createElement('h2', null, data.howItWorks.heading),
...data.howItWorks.rows.map((row) =>
createElement('div', { key: row.label },
createElement('span', null, row.label),
createElement('p', null, row.body),
)
),
)
}
function SupportContent({ data }: { data: LambdaPrizeCopySection }) {
return createElement(
'div',
null,
createElement('h2', null, data.support.heading),
createElement('p', null, data.support.body),
)
}
describe('LambdaPrizePage hero section driven from data', () => {
it('renders hero.heading from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('The frontier is open.')
expect(html).toContain('<h1')
})
it('renders hero.body from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Prize is the competitive prize framework powering the Logos network.')
})
it('renders hero.label from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Prize')
})
})
describe('LambdaPrizePage howItWorks section driven from data', () => {
it('renders howItWorks.heading from data', () => {
const html = renderToStaticMarkup(createElement(HowItWorksContent, { data: sectionData }))
expect(html).toContain('How It Works')
expect(html).toContain('<h2')
})
it('renders all 6 howItWorks rows from data', () => {
const html = renderToStaticMarkup(createElement(HowItWorksContent, { data: sectionData }))
expect(html).toContain('01')
expect(html).toContain('Prizes are broken into three categories')
expect(html).toContain('06')
expect(html).toContain('Winners will be notified directly')
})
})
describe('LambdaPrizePage support section driven from data', () => {
it('renders support.heading from data', () => {
const html = renderToStaticMarkup(createElement(SupportContent, { data: sectionData }))
expect(html).toContain('Get Support')
expect(html).toContain('<h2')
})
it('renders support.body from data', () => {
const html = renderToStaticMarkup(createElement(SupportContent, { data: sectionData }))
expect(html).toContain('Find examples, tutorials, and community channels for building on Logos.')
})
it('does not force support links when href copy is omitted', () => {
const html = renderToStaticMarkup(createElement(Support, { copy: sectionData.support }))
expect(html).toContain('Get Support')
expect(html).toContain('Help Center')
expect(html).toContain('View')
expect(html).toContain('Install')
expect(html).toContain('Connect')
expect(html).not.toContain('<a ')
expect(html).not.toContain('href=')
})
it('keeps production support actions linked to their intended destinations', () => {
const section = lambdaPrizePage.sections.find(
(candidate) => candidate.componentType === 'lambdaPrizeCopy'
)
expect(section?.support.rows).toEqual([
expect.objectContaining({
label: 'Sample Apps',
href: 'https://github.com/logos-co/eth-lez-atomic-swaps',
}),
expect.objectContaining({
label: 'Demos and Tutorials',
href: 'https://www.youtube.com/playlist?list=PLZe53tXAogqMdZSKhY316YKn3_tJ0RWZ7',
}),
expect.objectContaining({
label: 'Install Basecamp',
href: '/basecamp',
}),
expect.objectContaining({
label: 'Connect with Logos',
href: 'https://discord.gg/logosnetwork',
}),
])
})
})
@@ -15,9 +15,12 @@ export function TertiaryCta({
children: string
tone?: 'dark' | 'light'
}) {
const isExternal = /^https?:\/\//.test(href)
return (
<Link
href={href}
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className={`inline-flex cursor-pointer items-center gap-1 border-b pb-0.5 font-mono text-[10px] font-semibold uppercase leading-[1.35] ${
tone === 'light'
? 'border-brand-off-white/50 text-brand-off-white'
@@ -1,9 +1,16 @@
import ContentWidth from '@/components/layout/content-width'
import { ROUTES } from '@/constants/routes'
import { TertiaryCta } from './atoms'
import type { LambdaPrizePageCopy } from './types'
function SupportLabel({ children }: { children: string }) {
return (
<span className="inline-flex items-center border-b border-brand-dark-green/20 pb-0.5 font-mono text-[10px] leading-[1.35] font-semibold text-brand-dark-green/70 uppercase">
{children}
</span>
)
}
export function Support({ copy }: { copy: LambdaPrizePageCopy['support'] }) {
return (
<section className="h-[406px] border-t border-brand-dark-green/10 bg-brand-off-white px-0 pt-10 pb-10 text-brand-dark-green lg:h-[421px] lg:px-3 lg:pb-12">
@@ -13,9 +20,15 @@ export function Support({ copy }: { copy: LambdaPrizePageCopy['support'] }) {
<p className="text-mono-s col-start-1 row-start-2 w-[178px] lg:col-start-auto lg:row-start-auto lg:w-[226px]">
{copy.body}
</p>
<div className="col-start-2 row-start-1 lg:col-start-auto lg:row-start-auto">
<TertiaryCta href={ROUTES.faq}>{copy.cta}</TertiaryCta>
</div>
{copy.ctaHref ? (
<div className="col-start-2 row-start-1 lg:col-start-auto lg:row-start-auto">
<TertiaryCta href={copy.ctaHref}>{copy.cta}</TertiaryCta>
</div>
) : (
<div className="col-start-2 row-start-1 lg:col-start-auto lg:row-start-auto">
<SupportLabel>{copy.cta}</SupportLabel>
</div>
)}
</div>
<div className="mt-6 lg:mt-8">
{copy.rows.map((row, index) => (
@@ -35,7 +48,11 @@ export function Support({ copy }: { copy: LambdaPrizePageCopy['support'] }) {
{row.body}
</p>
<div>
<TertiaryCta href={ROUTES.faq}>{row.action}</TertiaryCta>
{row.href ? (
<TertiaryCta href={row.href}>{row.action}</TertiaryCta>
) : (
<SupportLabel>{row.action}</SupportLabel>
)}
</div>
</div>
))}
@@ -17,6 +17,7 @@ export interface PrizeCopy {
export interface SupportRowCopy extends RowCopy {
action: string
href?: string
}
export interface LambdaPrizePageCopy {
@@ -56,6 +57,7 @@ export interface LambdaPrizePageCopy {
heading: string
body: string
cta: string
ctaHref?: string
rows: SupportRowCopy[]
}
}
+30 -147
View File
@@ -1,19 +1,19 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { LambdaPrizeCopySection } from '@repo/content/schemas'
import { ROUTES } from '@/constants/routes'
import { getHomeTechStackOverview } from '@/lib/tech-stack-overview'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { LambdaPrizePage } from './_sections/lambda-prize-page'
const NAMESPACE = 'pages.lambdaPrize'
const ROUTE = ROUTES.lambdaPrize
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.lambdaPrize,
})
const findSection = createSectionFinder('lambda-prize')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function Page({
params,
@@ -24,153 +24,36 @@ export default async function Page({
if (!isActiveLocale(locale)) {
throw new Error(`LambdaPrizePage received non-active locale "${locale}"`)
}
const t = await getTranslations({ locale, namespace: NAMESPACE })
const page = await getPageCopy(ROUTE, locale)
const techStack = await getHomeTechStackOverview(locale)
const data = findSection<LambdaPrizeCopySection>(
page.sections,
'lambdaPrizeCopy',
'lambdaPrize.copy',
)
return (
<LambdaPrizePage
techStack={techStack}
copy={{
hero: {
label: t('hero.label'),
heading: t('hero.heading'),
body: t('hero.body'),
primaryCta: t('hero.primaryCta'),
secondaryCta: t('hero.secondaryCta'),
},
howItWorks: {
heading: t('howItWorks.heading'),
rows: [
{
label: t('howItWorks.rows.1.label'),
body: t('howItWorks.rows.1.body'),
},
{
label: t('howItWorks.rows.2.label'),
body: t('howItWorks.rows.2.body'),
},
{
label: t('howItWorks.rows.3.label'),
body: t('howItWorks.rows.3.body'),
},
{
label: t('howItWorks.rows.4.label'),
body: t('howItWorks.rows.4.body'),
},
{
label: t('howItWorks.rows.5.label'),
body: t('howItWorks.rows.5.body'),
},
{
label: t('howItWorks.rows.6.label'),
body: t('howItWorks.rows.6.body'),
},
],
},
evaluation: {
heading: t('evaluation.heading'),
rows: [
{
label: t('evaluation.rows.1.label'),
body: t('evaluation.rows.1.body'),
},
{
label: t('evaluation.rows.2.label'),
body: t('evaluation.rows.2.body'),
},
],
primaryCta: t('evaluation.primaryCta'),
secondaryCta: t('evaluation.secondaryCta'),
},
hero: data.hero,
howItWorks: data.howItWorks,
evaluation: data.evaluation,
featured: {
heading: t('featured.heading'),
prizes: [
{
meta: [
t('featured.prizes.1.meta.id'),
t('featured.prizes.1.meta.effort'),
t('featured.prizes.1.meta.prize'),
],
title: t('featured.prizes.1.title'),
body: t('featured.prizes.1.body'),
status: t('featured.status'),
href: t('featured.prizes.1.url'),
},
{
meta: [
t('featured.prizes.2.meta.id'),
t('featured.prizes.2.meta.effort'),
t('featured.prizes.2.meta.prize'),
],
title: t('featured.prizes.2.title'),
body: t('featured.prizes.2.body'),
status: t('featured.status'),
href: t('featured.prizes.2.url'),
},
{
meta: [
t('featured.prizes.3.meta.id'),
t('featured.prizes.3.meta.effort'),
t('featured.prizes.3.meta.prize'),
],
title: t('featured.prizes.3.title'),
body: t('featured.prizes.3.body'),
status: t('featured.status'),
href: t('featured.prizes.3.url'),
},
],
},
about: {
heading: t('about.heading'),
body: t('about.body'),
primaryCta: t('about.primaryCta'),
secondaryCta: t('about.secondaryCta'),
rows: [
{
label: t('about.rows.1.label'),
body: t('about.rows.1.body'),
},
{
label: t('about.rows.2.label'),
body: t('about.rows.2.body'),
},
{
label: t('about.rows.3.label'),
body: t('about.rows.3.body'),
},
],
},
techStack: {
startBuildingCta: t('techStack.startBuildingCta'),
docsCta: t('techStack.docsCta'),
},
support: {
heading: t('support.heading'),
body: t('support.body'),
cta: t('support.cta'),
rows: [
{
label: t('support.rows.1.label'),
body: t('support.rows.1.body'),
action: t('support.rows.1.action'),
},
{
label: t('support.rows.2.label'),
body: t('support.rows.2.body'),
action: t('support.rows.2.action'),
},
{
label: t('support.rows.3.label'),
body: t('support.rows.3.body'),
action: t('support.rows.3.action'),
},
{
label: t('support.rows.4.label'),
body: t('support.rows.4.body'),
action: t('support.rows.4.action'),
},
],
heading: data.featured.heading,
prizes: data.featured.prizes.map((prize) => ({
meta: [prize.meta.id, prize.meta.effort, prize.meta.prize],
title: prize.title,
body: prize.body,
status: data.featured.status,
href: prize.url,
})),
},
about: data.about,
techStack: data.techStack,
support: data.support,
}}
/>
)
@@ -0,0 +1,112 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { BroadcastCopySection } from '@repo/content/schemas'
const sectionData: BroadcastCopySection = {
componentType: 'broadcastCopy',
key: 'logosBroadcastNetwork.copy',
heading: 'Logos Broadcast Network',
intro: {
primary: 'Live calls, technical sessions, and community programming from across the Logos network.',
secondary: 'Live streams, updates, and regular programming across the entire Logos network.',
},
events: {
heading: 'Upcoming Events',
fallbackDescription: 'Live streams, updates, and regular programming from the Logos network.',
host: 'Host',
nextShow: 'Next Show',
yearLabel: 'Year',
monthLabel: 'Month',
todayLabel: 'Today',
},
pastEpisodes: {
heading: 'Past Episodes',
},
listenOnApp: 'Listen on an app',
}
function HeroContent({ data }: { data: BroadcastCopySection }) {
return createElement(
'section',
null,
createElement('h1', null, data.heading),
createElement('p', null, data.intro.primary),
createElement('p', null, data.intro.secondary),
)
}
function EventsContent({ data }: { data: BroadcastCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.events.heading),
createElement('span', null, data.events.host),
createElement('span', null, data.events.nextShow),
createElement('span', null, data.events.yearLabel),
createElement('span', null, data.events.monthLabel),
createElement('span', null, data.events.todayLabel),
createElement('p', null, data.events.fallbackDescription),
)
}
function PastEpisodesContent({ data }: { data: BroadcastCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.pastEpisodes.heading),
createElement('span', null, data.listenOnApp),
)
}
describe('BroadcastNetworkPage hero section driven from data', () => {
it('renders heading from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Logos Broadcast Network')
expect(html).toContain('<h1')
})
it('renders intro.primary from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Live calls, technical sessions, and community programming from across the Logos network.')
})
it('renders intro.secondary from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Live streams, updates, and regular programming across the entire Logos network.')
})
})
describe('BroadcastNetworkPage events section driven from data', () => {
it('renders events.heading from data', () => {
const html = renderToStaticMarkup(createElement(EventsContent, { data: sectionData }))
expect(html).toContain('Upcoming Events')
expect(html).toContain('<h2')
})
it('renders all calendar labels from data', () => {
const html = renderToStaticMarkup(createElement(EventsContent, { data: sectionData }))
expect(html).toContain('Host')
expect(html).toContain('Next Show')
expect(html).toContain('Year')
expect(html).toContain('Month')
expect(html).toContain('Today')
})
it('renders events.fallbackDescription from data', () => {
const html = renderToStaticMarkup(createElement(EventsContent, { data: sectionData }))
expect(html).toContain('Live streams, updates, and regular programming from the Logos network.')
})
})
describe('BroadcastNetworkPage past episodes section driven from data', () => {
it('renders pastEpisodes.heading from data', () => {
const html = renderToStaticMarkup(createElement(PastEpisodesContent, { data: sectionData }))
expect(html).toContain('Past Episodes')
})
it('renders listenOnApp from data', () => {
const html = renderToStaticMarkup(createElement(PastEpisodesContent, { data: sectionData }))
expect(html).toContain('Listen on an app')
})
})
@@ -1,29 +1,19 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { BroadcastCopySection } from '@repo/content/schemas'
import { ROUTES } from '@/constants/routes'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { getBroadcastEvents, getLatestBlogPodcasts } from '@/lib/blog-engine'
import { BroadcastNetworkPage } from './_sections/broadcast-network-page'
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({
locale,
namespace: 'pages.logosBroadcastNetwork',
})
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.logosBroadcastNetwork,
})
}
const ROUTE = ROUTES.logosBroadcastNetwork
const findSection = createSectionFinder('logos-broadcast-network')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function LogosBroadcastNetworkPage({
params,
@@ -37,11 +27,8 @@ export default async function LogosBroadcastNetworkPage({
)
}
const [t, podcasts, events] = await Promise.all([
getTranslations({
locale,
namespace: 'pages.logosBroadcastNetwork',
}),
const [page, podcasts, events] = await Promise.all([
getPageCopy(ROUTE, locale),
getLatestBlogPodcasts(20),
getBroadcastEvents(),
])
@@ -52,23 +39,29 @@ export default async function LogosBroadcastNetworkPage({
)
}
const data = findSection<BroadcastCopySection>(
page.sections,
'broadcastCopy',
'logosBroadcastNetwork.copy',
)
return (
<BroadcastNetworkPage
podcasts={podcasts}
events={events}
copy={{
title: t('heading'),
introPrimary: t('intro.primary'),
introSecondary: t('intro.secondary'),
eventFallbackDescription: t('events.fallbackDescription'),
eventHost: t('events.host'),
eventNextShow: t('events.nextShow'),
upcomingEvents: t('events.heading'),
calendarYearLabel: t('events.yearLabel'),
calendarMonthLabel: t('events.monthLabel'),
calendarTodayLabel: t('events.todayLabel'),
pastEpisodes: t('pastEpisodes.heading'),
listenOnApp: t('listenOnApp'),
title: data.heading,
introPrimary: data.intro.primary,
introSecondary: data.intro.secondary,
eventFallbackDescription: data.events.fallbackDescription,
eventHost: data.events.host,
eventNextShow: data.events.nextShow,
upcomingEvents: data.events.heading,
calendarYearLabel: data.events.yearLabel,
calendarMonthLabel: data.events.monthLabel,
calendarTodayLabel: data.events.todayLabel,
pastEpisodes: data.pastEpisodes.heading,
listenOnApp: data.listenOnApp,
}}
/>
)
+31 -38
View File
@@ -1,9 +1,12 @@
import type { CSSProperties } from 'react'
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { ManifestoCopySection } from '@repo/content/schemas'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
const PARAGRAPH_TOPS = [
0, 174, 324, 474, 648, 798, 924, 1074, 1176, 1302, 1428, 1506, 1608, 1710,
@@ -12,12 +15,6 @@ const PARAGRAPH_TOPS = [
] as const
const DESKTOP_WORD_SPACING_PARAGRAPH_INDEXES = new Set([11])
type ManifestoCopy = {
author: string[]
body: string[]
more: string[]
}
const DESKTOP_PARAGRAPH_STYLE = (index: number): CSSProperties => ({
top: PARAGRAPH_TOPS[index],
})
@@ -27,21 +24,11 @@ const MORE_HREFS = [
EXTERNAL_URLS.logosGenealogyArticle,
] as const
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.manifesto' })
const ROUTE = ROUTES.manifesto
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.manifesto,
})
}
const findSection = createSectionFinder('manifesto')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function ManifestoPage({
params,
@@ -49,13 +36,19 @@ export default async function ManifestoPage({
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.manifesto' })
const copy: ManifestoCopy = {
author: t.raw('author') as string[],
body: t.raw('body') as string[],
more: t.raw('more') as string[],
if (!isActiveLocale(locale)) {
throw new Error(`ManifestoPage received non-active locale "${locale}"`)
}
const moreLinks = copy.more.map((label, index) => ({
const page = await getPageCopy(ROUTE, locale)
const data = findSection<ManifestoCopySection>(
page.sections,
'manifestoCopy',
'manifesto.copy',
)
const moreLinks = data.more.map((label, index) => ({
label,
href: MORE_HREFS[index],
}))
@@ -64,14 +57,14 @@ export default async function ManifestoPage({
<div className="overflow-x-hidden bg-brand-off-white text-brand-dark-green desktop:h-[4669px] desktop:overflow-hidden">
<section className="relative flex flex-col items-center justify-center px-3 pt-28 pb-28 desktop:block desktop:h-[697px] desktop:p-0">
<h1 className="font-display mx-auto w-full max-w-[352px] text-center text-[24px] leading-none tracking-[-0.03em] min-[768px]:max-[1439px]:max-w-[620px] min-[768px]:max-[1439px]:text-[44px] min-[768px]:max-[1439px]:leading-[0.95] desktop:absolute desktop:top-[175px] desktop:left-1/2 desktop:w-[1178px] desktop:max-w-none desktop:-translate-x-1/2 desktop:text-[96px] desktop:leading-[0.98] desktop:tracking-[-0.04em]">
<span className="desktop:hidden">{t('heading')}</span>
<span className="desktop:hidden">{data.heading}</span>
<span className="hidden desktop:block">
<span className="block">{t('headingLine1')}</span>
<span className="block">{t('headingLine2')}</span>
<span className="block">{data.headingLine1}</span>
<span className="block">{data.headingLine2}</span>
</span>
</h1>
<p className="font-mono-body mx-auto mt-6 w-[102px] text-center text-[10px] leading-[1.3] tracking-[0px] desktop:absolute desktop:top-[426px] desktop:left-1/2 desktop:mt-0 desktop:-translate-x-1/2">
{copy.author.map((line) => (
{data.author.map((line) => (
<span key={line} className="block">
{line}
</span>
@@ -87,18 +80,18 @@ export default async function ManifestoPage({
<div className="mx-auto max-w-[698px] desktop:absolute desktop:top-[94px] desktop:left-1/2 desktop:h-[464px] desktop:w-[698px] desktop:-translate-x-1/2">
<section className="desktop:contents">
<h2 className="font-display text-center text-[24px] leading-none tracking-[-0.03em] min-[768px]:max-[1439px]:text-[36px] desktop:absolute desktop:top-0 desktop:left-[73px] desktop:w-[552px] desktop:text-[36px]">
{t('abstractHeading')}
{data.abstractHeading}
</h2>
<p className="font-display mt-6 text-center text-[14px] leading-[1.2] tracking-[0px] [overflow-wrap:anywhere] min-[768px]:max-[1439px]:text-[20px] min-[768px]:max-[1439px]:leading-[24px] min-[768px]:max-[1439px]:[overflow-wrap:normal] desktop:absolute desktop:top-[50px] desktop:left-0 desktop:mt-0 desktop:w-[698px] desktop:text-justify desktop:text-[20px] desktop:leading-[24px] desktop:tracking-[-0.03em] desktop:[overflow-wrap:normal]">
{t('abstractBody')}
{data.abstractBody}
</p>
</section>
<section className="hidden desktop:contents">
<h2 className="font-display text-center text-[24px] leading-none tracking-[-0.03em] min-[768px]:max-[1439px]:text-[36px] desktop:absolute desktop:top-[376px] desktop:left-[73px] desktop:w-[552px] desktop:text-[36px]">
{t('keywordsHeading')}
{data.keywordsHeading}
</h2>
<p className="font-display mt-6 text-center text-[14px] leading-[1.2] tracking-[0px] [overflow-wrap:anywhere] min-[768px]:max-[1439px]:text-[20px] min-[768px]:max-[1439px]:leading-[24px] min-[768px]:max-[1439px]:[overflow-wrap:normal] desktop:absolute desktop:top-[426px] desktop:left-0 desktop:mt-0 desktop:w-[698px] desktop:text-justify desktop:text-[20px] desktop:leading-[24px] desktop:tracking-[-0.03em] desktop:[overflow-wrap:normal]">
{t('keywords')}
{data.keywords}
</p>
</section>
</div>
@@ -110,7 +103,7 @@ export default async function ManifestoPage({
>
<article className="font-sans mx-auto text-center text-[14px] leading-[1.2] tracking-[0px] min-[768px]:max-[1439px]:max-w-[640px] min-[768px]:max-[1439px]:text-[18px] min-[768px]:max-[1439px]:leading-[21.6px] desktop:absolute desktop:top-[107px] desktop:left-1/2 desktop:h-[3078px] desktop:w-[853px] desktop:max-w-none desktop:-translate-x-1/2 desktop:font-article desktop:text-[20px] desktop:leading-[24px] desktop:tracking-[-0.03em]">
<div className="desktop:relative desktop:h-[2918px]">
{copy.body.map((paragraph, index) => (
{data.body.map((paragraph, index) => (
<p
key={paragraph}
style={DESKTOP_PARAGRAPH_STYLE(index)}
@@ -126,7 +119,7 @@ export default async function ManifestoPage({
</div>
<aside className="font-mono-body mt-12 text-[10px] leading-[1.3] tracking-[0px] desktop:absolute desktop:top-[2995px] desktop:left-0 desktop:mt-0 desktop:h-[88px] desktop:w-[306px] desktop:text-left">
<h2 className="font-bold">{t('moreHeading')}</h2>
<h2 className="font-bold">{data.moreHeading}</h2>
<div className="mt-3 space-y-3 desktop:mt-[12px] desktop:space-y-[12px]">
{moreLinks.map((item) => (
<p key={item.label}>
@@ -0,0 +1,169 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { MediaCopySection } from '@repo/content/schemas'
const sectionData: MediaCopySection = {
componentType: 'mediaCopy',
key: 'media.copy',
hero: {
line1: 'Logos',
line2: 'Media',
tagline: 'Articles, news, podcasts, and media throughout the Logos Network.',
},
nav: {
label: 'Blog sections',
articles: 'Articles',
podcasts: 'Podcasts',
broadcast: 'Logos Broadcast Network',
},
articles: {
heading: 'Articles',
readArticle: 'Read Article',
seeMore: 'See more articles',
},
podcasts: {
heading: 'Podcasts',
media: 'Media',
heroTitle: 'Logos Podcast',
heroDescription: 'Logos Podcast is a bi-weekly interview show about building sovereign communities: the people, philosophies, challenges, and solutions. We invite the world\'s foremost experts and radical thinkers to discuss how to emancipate from repressive systems through new social, political, and economic institutions.',
latestEpisode: 'Latest episode',
seeAllEpisodes: 'See all episodes',
listenOnApp: 'Listen on an app',
cta: 'Logos podcast',
episodePrefix: 'Episode',
fallbackEpisode: 'Logos Podcast',
},
broadcast: {
heading: 'Logos Broadcast Network',
description: 'Regular programming, live sessions, and office hours for your viewing pleasure',
media: 'Media',
latestEpisode: 'Latest episode',
cta: 'Learn more',
},
}
function HeroContent({ data }: { data: MediaCopySection }) {
return createElement(
'div',
null,
createElement('span', null, data.hero.line1),
createElement('span', null, data.hero.line2),
createElement('p', null, data.hero.tagline),
)
}
function NavContent({ data }: { data: MediaCopySection }) {
return createElement(
'nav',
null,
createElement('span', null, data.nav.label),
createElement('span', null, data.nav.articles),
createElement('span', null, data.nav.podcasts),
createElement('span', null, data.nav.broadcast),
)
}
function ArticlesContent({ data }: { data: MediaCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.articles.heading),
createElement('span', null, data.articles.readArticle),
createElement('span', null, data.articles.seeMore),
)
}
function PodcastsContent({ data }: { data: MediaCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.podcasts.heading),
createElement('span', null, data.podcasts.cta),
createElement('span', null, data.podcasts.listenOnApp),
)
}
function BroadcastContent({ data }: { data: MediaCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.broadcast.heading),
createElement('p', null, data.broadcast.description),
createElement('span', null, data.broadcast.cta),
)
}
describe('MediaPage hero section driven from data', () => {
it('renders hero.line1 from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Logos')
})
it('renders hero.line2 from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Media')
})
it('renders hero.tagline from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Articles, news, podcasts, and media throughout the Logos Network.')
})
})
describe('MediaPage nav section driven from data', () => {
it('renders all nav labels from data', () => {
const html = renderToStaticMarkup(createElement(NavContent, { data: sectionData }))
expect(html).toContain('Blog sections')
expect(html).toContain('Articles')
expect(html).toContain('Podcasts')
expect(html).toContain('Logos Broadcast Network')
})
})
describe('MediaPage articles section driven from data', () => {
it('renders articles.heading from data', () => {
const html = renderToStaticMarkup(createElement(ArticlesContent, { data: sectionData }))
expect(html).toContain('Articles')
expect(html).toContain('<h2')
})
it('renders articles.readArticle from data', () => {
const html = renderToStaticMarkup(createElement(ArticlesContent, { data: sectionData }))
expect(html).toContain('Read Article')
})
it('renders articles.seeMore from data', () => {
const html = renderToStaticMarkup(createElement(ArticlesContent, { data: sectionData }))
expect(html).toContain('See more articles')
})
})
describe('MediaPage podcasts section driven from data', () => {
it('renders podcasts.heading from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsContent, { data: sectionData }))
expect(html).toContain('Podcasts')
})
it('renders podcasts.cta from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsContent, { data: sectionData }))
expect(html).toContain('Logos podcast')
})
})
describe('MediaPage broadcast section driven from data', () => {
it('renders broadcast.heading from data', () => {
const html = renderToStaticMarkup(createElement(BroadcastContent, { data: sectionData }))
expect(html).toContain('Logos Broadcast Network')
})
it('renders broadcast.description from data', () => {
const html = renderToStaticMarkup(createElement(BroadcastContent, { data: sectionData }))
expect(html).toContain('Regular programming, live sessions, and office hours for your viewing pleasure')
})
it('renders broadcast.cta from data', () => {
const html = renderToStaticMarkup(createElement(BroadcastContent, { data: sectionData }))
expect(html).toContain('Learn more')
})
})
+45 -43
View File
@@ -1,5 +1,6 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { MediaCopySection } from '@repo/content/schemas'
import { ROUTES } from '@/constants/routes'
import {
@@ -7,7 +8,8 @@ import {
getBlogPageData,
repeatToLength,
} from '@/lib/blog-engine'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import {
ArticleEntry,
@@ -20,20 +22,11 @@ import {
} from './_sections/articles'
import { PodcastsSection } from './_sections/podcasts'
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.blog' })
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.media,
})
}
const ROUTE = ROUTES.media
const findSection = createSectionFinder('media')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function BlogPage({
params,
@@ -45,7 +38,11 @@ export default async function BlogPage({
throw new Error(`BlogPage received non-active locale "${locale}"`)
}
const { articles, podcasts } = await getBlogPageData()
const [page, { articles, podcasts }] = await Promise.all([
getPageCopy(ROUTE, locale),
getBlogPageData(),
])
if (articles.length === 0) {
throw new Error('Blog page requires at least one article from blog API')
}
@@ -53,30 +50,35 @@ export default async function BlogPage({
throw new Error('Blog page requires at least one podcast from blog API')
}
const data = findSection<MediaCopySection>(
page.sections,
'mediaCopy',
'media.copy',
)
const repeatedArticles = repeatToLength(articles, 12)
const galleryArticles = articles.slice(0, 4)
const latestBroadcastEpisode = podcasts[0]
const featuredArticle =
articles.find((article) => article.href.endsWith('/article/realfi-hack')) ??
articles[0]
const t = await getTranslations({ locale, namespace: 'pages.blog' })
return (
<div className="bg-accent-tan pt-10">
<BlogHero
copy={{
heroTagline: t('hero.tagline'),
heroHeadingLine1: t('hero.line1'),
heroHeadingLine2: t('hero.line2'),
navLabel: t('nav.label'),
navArticles: t('nav.articles'),
navPodcasts: t('nav.podcasts'),
navBroadcast: t('nav.broadcast'),
heroTagline: data.hero.tagline,
heroHeadingLine1: data.hero.line1,
heroHeadingLine2: data.hero.line2,
navLabel: data.nav.label,
navArticles: data.nav.articles,
navPodcasts: data.nav.podcasts,
navBroadcast: data.nav.broadcast,
navBroadcastHref: '#broadcast',
}}
/>
<section className="bg-accent-tan">
<ArticlesHeading label={t('articles.heading')} />
<ArticlesHeading label={data.articles.heading} />
{repeatedArticles.slice(0, 4).map((article, index) => (
<ArticleEntry key={`top-${index}`} article={article} index={index} />
))}
@@ -86,7 +88,7 @@ export default async function BlogPage({
))}
<FeaturedArticle
article={featuredArticle}
readArticleLabel={t('articles.readArticle')}
readArticleLabel={data.articles.readArticle}
/>
{repeatedArticles.slice(8, 12).map((article, index) => (
<ArticleEntry
@@ -97,34 +99,34 @@ export default async function BlogPage({
))}
<ArticlesCta
href={`${BLOG_ORIGIN}/search?type=article`}
label={t('articles.seeMore')}
label={data.articles.seeMore}
/>
</section>
<PodcastsSection
podcasts={podcasts}
ctaHref={ROUTES.podcast}
copy={{
heading: t('podcasts.heading'),
media: t('podcasts.media'),
heroTitle: t('podcasts.heroTitle'),
heroDescription: t('podcasts.heroDescription'),
latestEpisode: t('podcasts.latestEpisode'),
seeAllEpisodes: t('podcasts.seeAllEpisodes'),
listenOnApp: t('podcasts.listenOnApp'),
cta: t('podcasts.cta'),
episodePrefix: t('podcasts.episodePrefix'),
fallbackEpisode: t('podcasts.fallbackEpisode'),
heading: data.podcasts.heading,
media: data.podcasts.media,
heroTitle: data.podcasts.heroTitle,
heroDescription: data.podcasts.heroDescription,
latestEpisode: data.podcasts.latestEpisode,
seeAllEpisodes: data.podcasts.seeAllEpisodes,
listenOnApp: data.podcasts.listenOnApp,
cta: data.podcasts.cta,
episodePrefix: data.podcasts.episodePrefix,
fallbackEpisode: data.podcasts.fallbackEpisode,
}}
/>
<BroadcastSection
href={ROUTES.logosBroadcastNetwork}
latestEpisodeTitle={latestBroadcastEpisode.title}
copy={{
broadcastHeading: t('broadcast.heading'),
broadcastDescription: t('broadcast.description'),
media: t('broadcast.media'),
latestEpisode: t('broadcast.latestEpisode'),
broadcastCta: t('broadcast.cta'),
broadcastHeading: data.broadcast.heading,
broadcastDescription: data.broadcast.description,
media: data.broadcast.media,
latestEpisode: data.broadcast.latestEpisode,
broadcastCta: data.broadcast.cta,
}}
/>
</div>
+17 -10
View File
@@ -1,10 +1,11 @@
import { getTranslations } from 'next-intl/server'
import {
getCircleInitiatives,
getCircleResources,
getCirclesSettings,
getPageCopy,
} from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { MovementCopySection } from '@repo/content/schemas'
import { MovementPageView } from '@/components/sections/movement/movement-page'
import { ROUTES } from '@/constants/routes'
@@ -12,14 +13,14 @@ import {
getActiveCircleMarkers,
getUpcomingCircleEvents,
} from '@/lib/active-circles'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
const NAMESPACE = 'pages.movement'
const ROUTE = ROUTES.movement
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.movement,
})
const findSection = createSectionFinder('movement')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function MovementPage({
params,
@@ -31,9 +32,9 @@ export default async function MovementPage({
throw new Error(`MovementPage received non-active locale "${locale}"`)
}
const t = await getTranslations({ locale, namespace: NAMESPACE })
const [circlesSettings, mapMarkers, upcomingEvents, initiatives, resources] =
const [page, circlesSettings, mapMarkers, upcomingEvents, initiatives, resources] =
await Promise.all([
getPageCopy(ROUTE, locale),
getCirclesSettings(locale),
getActiveCircleMarkers(),
getUpcomingCircleEvents(Infinity),
@@ -41,9 +42,15 @@ export default async function MovementPage({
getCircleResources({ locale, status: 'published' }),
])
const movementCopy = findSection<MovementCopySection>(
page.sections,
'movementCopy',
'movement.copy',
)
return (
<MovementPageView
t={t}
data={movementCopy}
circlesSettings={circlesSettings}
mapMarkers={mapMarkers}
upcomingEvents={upcomingEvents}
@@ -0,0 +1,211 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { NodeProgrammeCopySection } from '@repo/content/schemas'
// 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 @acid-info/logos-ui to avoid UI library dependencies
vi.mock('@acid-info/logos-ui', () => ({
LogosMark: () => createElement('span', { 'data-testid': 'logos-mark' }),
}))
// Stub @/components/layout/content-width
vi.mock('@/components/layout/content-width', () => ({
default: ({ children, className }: { children: React.ReactNode; className?: string }) =>
createElement('div', { className }, children),
}))
// Stub @/components/ui
vi.mock('@/components/ui', () => ({
Button: ({ children, href, className }: { children: React.ReactNode; href?: string; className?: string }) =>
createElement('a', { href, className }, children),
ButtonArrowIcon: () => createElement('span', {}, '→'),
}))
// Stub NodeProgrammeSignupForm (client component, tested separately)
vi.mock('../node-programme-signup-form', () => ({
NodeProgrammeSignupForm: ({ copy }: { copy: { title: string } }) =>
createElement('div', { 'data-testid': 'signup-form' }, copy.title),
}))
const sectionData: NodeProgrammeCopySection = {
componentType: 'nodeProgrammeCopy',
key: 'nodeProgramme.copy',
hero: {
eyebrow: 'Node Programme',
title: 'Be the first to join the node programme',
body: 'Sign up for early access to the upcoming node programme and help decentralise the network.',
cta: 'Sign up',
secondaryCta: 'View the stack',
guideCta: 'Node operator guide',
},
builders: {
eyebrow: 'Operate',
title: 'Calling builders and node operators',
body: 'The node programme is for anyone who wants to join our movement to revitalise civil society using decentralised technologies. Whether you are a builder or a node operator, there is a role for you.',
imageAlt: 'Logos node programme builders and node operators',
},
stack: {
title: 'The Logos technology stack is a unified ecosystem.',
titleMuted: 'Private-by-default. Built for real life.',
items: [
{
title: 'Blockchain',
body: 'Advanced privacy for a new era of decentralised applications and social institutions.',
icon: '/images/node-programme/blockchain.png',
alt: 'Blockchain',
},
{
title: 'Messaging',
body: 'Private peer-to-peer communication that resists surveillance and censorship.',
icon: '/images/node-programme/messaging.png',
alt: 'Messaging',
},
{
title: 'Storage',
body: 'Secure decentralised storage enabling fully decentralised apps and file sharing.',
icon: '/images/node-programme/storage.png',
alt: 'Storage',
},
],
},
useCases: {
title: 'Use cases.',
titleMuted: "We know today's systems are captured and brittle. We build alternatives to give people a choice. Build on Logos and serve your community.",
items: [
{ title: 'Community governance', body: 'Self-organising groups that define and enforce their own rules, voluntarily and transparently.' },
{ title: 'Corruption-resistant public registries', body: 'Tamper-proof records for votes, land, licences, and trade agreements.' },
{ title: 'Private financial networks', body: 'Money that moves securely and freely, without borders.' },
{ title: 'Decentralised archives', body: 'Permanent, censorship-resistant preservation of knowledge, culture, and history.' },
{ title: 'Secure communications', body: 'Resilient, private messaging for coordination in hostile environments.' },
],
},
signup: {
title: 'Sign up for the node programme',
emailLabel: 'Email address',
emailPlaceholder: 'Enter email',
roleLabel: 'Role',
rolePlaceholder: 'Select role',
roles: ['Node operator', 'Builder', 'Activist'],
submit: 'Submit',
submitting: 'Submitting',
invalidEmail: 'Please enter a valid email address.',
missingRole: 'Please select a role.',
genericError: 'Failed to submit. Please try again.',
success: 'Thanks for stepping in early.',
successLink: 'Join our server',
imageAlt: 'Forest landscape for the Logos node programme',
},
}
/** Inline render of the hero portion of the page. */
function HeroContent({ data }: { data: NodeProgrammeCopySection }) {
return createElement(
'div',
null,
createElement('p', null, data.hero.eyebrow),
createElement('h1', null, data.hero.title),
createElement('p', null, data.hero.body),
)
}
/** Inline render of the stack section. */
function StackContent({ data }: { data: NodeProgrammeCopySection }) {
return createElement(
'div',
null,
createElement('h2', null, data.stack.title, ' ', createElement('span', null, data.stack.titleMuted)),
...data.stack.items.map((item) =>
createElement('article', { key: item.title },
createElement('h3', null, item.title),
createElement('p', null, item.body),
)
),
)
}
/** Inline render of the use-cases section. */
function UseCasesContent({ data }: { data: NodeProgrammeCopySection }) {
return createElement(
'div',
null,
createElement('h2', null, data.useCases.title),
...data.useCases.items.map((item) =>
createElement('article', { key: item.title },
createElement('h3', null, item.title),
createElement('p', null, item.body),
)
),
)
}
/** Inline render of the signup title from data. */
function SignupTitleContent({ data }: { data: NodeProgrammeCopySection }) {
return createElement('h2', null, data.signup.title)
}
describe('NodeProgrammePage hero section driven from data', () => {
it('renders hero.title from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Be the first to join the node programme')
expect(html).toContain('<h1')
})
it('renders hero.eyebrow from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Node Programme')
})
it('renders hero.body from data', () => {
const html = renderToStaticMarkup(createElement(HeroContent, { data: sectionData }))
expect(html).toContain('Sign up for early access to the upcoming node programme')
})
})
describe('NodeProgrammePage stack section driven from data', () => {
it('renders stack.title from data', () => {
const html = renderToStaticMarkup(createElement(StackContent, { data: sectionData }))
expect(html).toContain('The Logos technology stack is a unified ecosystem.')
expect(html).toContain('<h2')
})
it('renders all stack items from data', () => {
const html = renderToStaticMarkup(createElement(StackContent, { data: sectionData }))
expect(html).toContain('Blockchain')
expect(html).toContain('Messaging')
expect(html).toContain('Storage')
})
})
describe('NodeProgrammePage useCases section driven from data', () => {
it('renders useCases.title from data', () => {
const html = renderToStaticMarkup(createElement(UseCasesContent, { data: sectionData }))
expect(html).toContain('Use cases.')
})
it('renders a useCases item title from data', () => {
const html = renderToStaticMarkup(createElement(UseCasesContent, { data: sectionData }))
expect(html).toContain('Community governance')
})
it('renders all 5 useCases items from data', () => {
const html = renderToStaticMarkup(createElement(UseCasesContent, { data: sectionData }))
expect(html).toContain('Corruption-resistant public registries')
expect(html).toContain('Private financial networks')
expect(html).toContain('Decentralised archives')
expect(html).toContain('Secure communications')
})
})
describe('NodeProgrammePage signup section driven from data', () => {
it('renders signup.title from data', () => {
const html = renderToStaticMarkup(createElement(SignupTitleContent, { data: sectionData }))
expect(html).toContain('Sign up for the node programme')
expect(html).toContain('<h2')
})
})
@@ -1,6 +1,5 @@
'use client'
import { useTranslations } from 'next-intl'
import { useId, useState, type FormEvent } from 'react'
import { Button } from '@/components/ui'
@@ -12,15 +11,32 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type SignupStatus = 'idle' | 'loading' | 'success' | 'error'
export function NodeProgrammeSignupForm() {
const t = useTranslations('pages.nodeProgramme.signup')
export interface NodeProgrammeSignupCopy {
emailLabel: string
emailPlaceholder: string
roleLabel: string
rolePlaceholder: string
roles: string[]
submit: string
submitting: string
invalidEmail: string
missingRole: string
genericError: string
success: string
successLink: string
}
interface NodeProgrammeSignupFormProps {
copy: NodeProgrammeSignupCopy
}
export function NodeProgrammeSignupForm({ copy }: NodeProgrammeSignupFormProps) {
const emailId = useId()
const roleId = useId()
const [email, setEmail] = useState('')
const [role, setRole] = useState('')
const [status, setStatus] = useState<SignupStatus>('idle')
const [error, setError] = useState('')
const roles = t.raw('roles') as string[]
const isLoading = status === 'loading'
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -28,13 +44,13 @@ export function NodeProgrammeSignupForm() {
if (!EMAIL_PATTERN.test(email)) {
setStatus('error')
setError(t('invalidEmail'))
setError(copy.invalidEmail)
return
}
if (!role) {
setStatus('error')
setError(t('missingRole'))
setError(copy.missingRole)
return
}
@@ -48,7 +64,7 @@ export function NodeProgrammeSignupForm() {
setStatus('success')
} catch (caught) {
setStatus('error')
setError(caught instanceof Error ? caught.message : t('genericError'))
setError(caught instanceof Error ? caught.message : copy.genericError)
}
}
@@ -59,7 +75,7 @@ export function NodeProgrammeSignupForm() {
>
<div className="grid gap-2 rounded-[4px] border border-brand-dark-green/40 bg-brand-off-white/90 p-2 backdrop-blur md:grid-cols-[1fr_170px_auto] md:items-center">
<label className="sr-only" htmlFor={emailId}>
{t('emailLabel')}
{copy.emailLabel}
</label>
<input
id={emailId}
@@ -68,13 +84,13 @@ export function NodeProgrammeSignupForm() {
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isLoading}
placeholder={t('emailPlaceholder')}
placeholder={copy.emailPlaceholder}
autoComplete="email"
className="text-mono-s min-h-10 w-full border border-brand-dark-green/20 bg-transparent px-3 text-brand-dark-green outline-none transition-colors placeholder:text-brand-dark-green/45 focus:border-brand-dark-green md:border-0"
/>
<label className="sr-only" htmlFor={roleId}>
{t('roleLabel')}
{copy.roleLabel}
</label>
<select
id={roleId}
@@ -84,8 +100,8 @@ export function NodeProgrammeSignupForm() {
disabled={isLoading}
className="text-mono-s h-10 w-full cursor-pointer rounded-[4px] border border-brand-dark-green/25 bg-transparent px-3 text-brand-dark-green outline-none transition-colors focus:border-brand-dark-green"
>
<option value="">{t('rolePlaceholder')}</option>
{roles.map((roleOption) => (
<option value="">{copy.rolePlaceholder}</option>
{copy.roles.map((roleOption) => (
<option key={roleOption} value={roleOption}>
{roleOption}
</option>
@@ -97,21 +113,21 @@ export function NodeProgrammeSignupForm() {
disabled={isLoading}
className="h-10 cursor-pointer rounded-[4px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? t('submitting') : t('submit')}
{isLoading ? copy.submitting : copy.submit}
</Button>
</div>
<div className="text-mono-s min-h-[34px] text-brand-dark-green">
{status === 'success' ? (
<p>
{t('success')}{' '}
{copy.success}{' '}
<a
href={EXTERNAL_URLS.discord}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer underline underline-offset-2"
>
{t('successLink')}
{copy.successLink}
</a>
.
</p>
+38 -40
View File
@@ -1,34 +1,24 @@
import Image from 'next/image'
import { getTranslations } from 'next-intl/server'
import type { ReactNode } from 'react'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { NodeProgrammeCopySection } from '@repo/content/schemas'
import { LogosMark } from '@acid-info/logos-ui'
import ContentWidth from '@/components/layout/content-width'
import { Button, ButtonArrowIcon } from '@/components/ui'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { NodeProgrammeSignupForm } from './node-programme-signup-form'
const NAMESPACE = 'pages.nodeProgramme'
const ROUTE = ROUTES.nodeProgramme
export const generateMetadata = createTranslatedPageMetadata({
namespace: NAMESPACE,
path: ROUTES.nodeProgramme,
})
const findSection = createSectionFinder('node-programme')
interface StackItem {
title: string
body: string
icon: string
alt: string
}
interface UseCaseItem {
title: string
body: string
}
export const generateMetadata = createPageMetadata(ROUTE)
function SectionShell({
children,
@@ -77,9 +67,17 @@ export default async function NodeProgramPage({
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: NAMESPACE })
const stackItems = t.raw('stack.items') as StackItem[]
const useCases = t.raw('useCases.items') as UseCaseItem[]
if (!isActiveLocale(locale)) {
throw new Error(`NodeProgramPage received non-active locale "${locale}"`)
}
const page = await getPageCopy(ROUTE, locale)
const data = findSection<NodeProgrammeCopySection>(
page.sections,
'nodeProgrammeCopy',
'nodeProgramme.copy',
)
return (
<main className="bg-brand-off-white">
@@ -98,35 +96,35 @@ export default async function NodeProgramPage({
<p className="text-mono-s absolute top-6 left-[calc(50%+6px)] flex w-[calc(50%-18px)] max-w-[178px] items-center gap-3 md:left-1/2 md:w-[226px] md:max-w-none md:translate-x-[6px]">
<LogosMark size={10} />
{t('hero.eyebrow')}
{data.hero.eyebrow}
</p>
<div className="absolute top-[279px] left-3 flex flex-col items-start gap-3 md:top-[11px] md:left-[83.33%] md:translate-x-[2px] md:gap-1.5">
<HeroAnchorLink href="#node-programme-signup">
{t('hero.cta')}
{data.hero.cta}
</HeroAnchorLink>
<Button
href={ROUTES.technologyStack}
variant="link"
className="cursor-pointer"
>
{t('hero.secondaryCta')}
{data.hero.secondaryCta}
</Button>
<Button
href={EXTERNAL_URLS.nodeOperatorGuide}
variant="link"
className="cursor-pointer"
>
{t('hero.guideCta')}
{data.hero.guideCta}
</Button>
</div>
<h1 className="absolute top-[140px] left-1/2 w-[min(464px,calc(100vw-24px))] -translate-x-1/2 text-center font-display text-[40px] leading-[0.86] tracking-[-0.03em] text-brand-dark-green md:w-[640px] md:text-[56px]">
{t('hero.title')}
{data.hero.title}
</h1>
<p className="text-mono-s absolute top-[279px] left-[calc(50%+6px)] w-[calc(50%-18px)] max-w-[178px] md:top-[307px] md:left-1/2 md:w-[226px] md:max-w-none md:translate-x-[6px]">
{t('hero.body')}
{data.hero.body}
</p>
</ContentWidth>
</section>
@@ -135,7 +133,7 @@ export default async function NodeProgramPage({
<div className="relative min-h-[280px] overflow-hidden rounded-xl md:col-span-6 md:min-h-[470px]">
<Image
src="/images/node-programme/builders.webp"
alt={t('builders.imageAlt')}
alt={data.builders.imageAlt}
fill
priority
sizes="(max-width: 768px) 100vw, 702px"
@@ -146,23 +144,23 @@ export default async function NodeProgramPage({
<div>
<p className="text-mono-s mb-5 flex items-center gap-3">
<LogosMark size={10} />
{t('builders.eyebrow')}
{data.builders.eyebrow}
</p>
<h2 className="text-h3 max-w-[560px]">{t('builders.title')}</h2>
<h2 className="text-h3 max-w-[560px]">{data.builders.title}</h2>
</div>
<p className="text-mono-s max-w-[486px]">{t('builders.body')}</p>
<p className="text-mono-s max-w-[486px]">{data.builders.body}</p>
</div>
</SectionShell>
<SectionShell className="grid gap-10 py-10 lg:grid-cols-12 lg:gap-3 lg:py-20">
<h2 className="text-h3 lg:col-span-7">
{t('stack.title')}{' '}
{data.stack.title}{' '}
<span className="text-brand-dark-green/45">
{t('stack.titleMuted')}
{data.stack.titleMuted}
</span>
</h2>
<div className="divide-y divide-brand-dark-green/15 border-y border-brand-dark-green/15 lg:col-span-6 lg:col-start-7">
{stackItems.map((item) => (
{data.stack.items.map((item) => (
<article
key={item.title}
className="grid gap-5 py-6 lg:grid-cols-6"
@@ -187,13 +185,13 @@ export default async function NodeProgramPage({
<SectionShell className="lg:border-t border-brand-dark-green/10 py-10 md:py-20">
<h2 className="text-h3 max-w-[1040px]">
{t('useCases.title')}{' '}
{data.useCases.title}{' '}
<span className="text-brand-dark-green/45">
{t('useCases.titleMuted')}
{data.useCases.titleMuted}
</span>
</h2>
<div className="mt-10 grid gap-3 md:grid-cols-3 lg:grid-cols-5">
{useCases.map((item, index) => (
{data.useCases.items.map((item, index) => (
<article
key={item.title}
className="flex min-h-[220px] flex-col justify-between rounded-xl bg-gray-01 p-4"
@@ -214,7 +212,7 @@ export default async function NodeProgramPage({
<div className="relative flex min-h-[560px] items-center justify-center overflow-hidden rounded-xl px-3 py-16 text-brand-dark-green md:min-h-[680px]">
<Image
src="/images/node-programme/woods.webp"
alt={t('signup.imageAlt')}
alt={data.signup.imageAlt}
fill
sizes="(max-width: 768px) 100vw, 1416px"
className="object-cover"
@@ -222,9 +220,9 @@ export default async function NodeProgramPage({
<div className="absolute inset-0 bg-brand-off-white/15" />
<div className="relative z-10 mx-auto flex w-full max-w-[720px] flex-col items-center gap-6 text-center">
<h2 className="text-h2 text-brand-dark-green">
{t('signup.title')}
{data.signup.title}
</h2>
<NodeProgrammeSignupForm />
<NodeProgrammeSignupForm copy={data.signup} />
</div>
</div>
</SectionShell>
+58 -28
View File
@@ -1,9 +1,14 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type {
HeroSection,
HomeAboutSection,
HomeBuilderPortalSection,
HomeChoosePathSection,
HomeDecideSection,
HomeSocialProofSection,
HomeStartBuildingSection,
HomeUseCasesSection,
RelatedArticlesSection,
TechStackOverviewSection,
} from '@repo/content/schemas'
@@ -41,25 +46,64 @@ export default async function HomePage({
if (!isActiveLocale(locale)) {
throw new Error(`HomePage received non-active locale "${locale}"`)
}
const [page, t] = await Promise.all([
getPageCopy(ROUTE, locale),
getTranslations({ locale, namespace: 'home.techStack' }),
])
const page = await getPageCopy(ROUTE, locale)
const hero = findSection<HeroSection>(page.sections, 'hero', 'home.atf')
const socialProof = findSection<HomeSocialProofSection>(
page.sections,
'homeSocialProof',
'home.socialProof'
)
const techStack = findSection<TechStackOverviewSection>(
page.sections,
'techStackOverview',
'home.techStack'
)
const paths = findSection<HomeChoosePathSection>(
page.sections,
'homeChoosePath',
'home.paths'
)
const blog = findSection<RelatedArticlesSection>(
page.sections,
'relatedArticles',
'home.blog'
)
const about = findSection<HomeAboutSection>(
page.sections,
'homeAbout',
'home.about'
)
const decide = findSection<HomeDecideSection>(
page.sections,
'homeDecide',
'home.decide'
)
const startBuilding = findSection<HomeStartBuildingSection>(
page.sections,
'homeStartBuilding',
'home.startBuilding'
)
const useCases = findSection<HomeUseCasesSection>(
page.sections,
'homeUseCases',
'home.useCases'
)
const builderPortal = findSection<HomeBuilderPortalSection>(
page.sections,
'homeBuilderPortal',
'home.builderPortal'
)
const [articles, socialProofStats, winnableIssuesCount] = await Promise.all([
getLatestBlogArticles(blog.visibleCount ?? 4),
getSocialProofStats(),
@@ -70,38 +114,24 @@ export default async function HomePage({
<>
<HeroSectionView data={hero} />
<SocialProofSection
data={socialProof}
stats={socialProofStats}
winnableIssuesCount={winnableIssuesCount}
/>
<FeatureCardsSection />
<AboutSection locale={locale} />
<DecideSection locale={locale} />
<UseCasesSection locale={locale} />
<BuilderPortalSection locale={locale} />
<FeatureCardsSection data={paths} />
<AboutSection data={about} />
<DecideSection data={decide} />
<UseCasesSection data={useCases} />
<BuilderPortalSection data={builderPortal} />
<TechStackSection
data={techStack}
networkingHref={ROUTES.networking}
foundationHref={ROUTES.technologyStack}
desktopAt1367
bottomSpacingClassName="pb-[112px]"
ctas={[
{
label: t('exploreStackCta'),
href: ROUTES.technologyStack,
},
{
label: t('startBuildingCta'),
href: ROUTES.getStarted,
variant: 'secondary',
},
{
label: t('docsCta'),
href: EXTERNAL_URLS.docs,
variant: 'secondary',
},
]}
ctas={techStack.ctas}
/>
<StartBuildingSection locale={locale} />
<StartBuildingSection data={startBuilding} />
<BlogSection data={blog} articles={articles} />
</>
)
@@ -0,0 +1,105 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { PodcastCopySection } from '@repo/content/schemas'
const sectionData: PodcastCopySection = {
componentType: 'podcastCopy',
key: 'podcast.copy',
heading: 'Logos Podcast',
eyebrow: 'Media',
backToMedia: 'Logos Media',
intro: {
description: 'Logos Podcast is a biweekly interview show about building sovereign communities: the people, philosophies, challenges, and solutions. We invite the world\'s foremost experts and radical thinkers to discuss how to emancipate from repressive systems through new social, political, and economic institutions.',
hostedBy: 'Hosted by Jarrad Hope.',
},
latestHeading: 'Latest Episodes',
latestEpisode: 'Latest episode',
listenOnApp: 'Listen on an app',
seeAllEpisodes: 'See all episodes',
episodePrefix: 'Episode',
fallbackEpisode: 'Logos Podcast',
podcastCta: 'Logos podcast',
}
function IntroContent({ data }: { data: PodcastCopySection }) {
return createElement(
'section',
null,
createElement('h1', null, data.heading),
createElement('span', null, data.eyebrow),
createElement('span', null, data.backToMedia),
createElement('p', null, data.intro.description),
createElement('p', null, data.intro.hostedBy),
)
}
function PodcastsListContent({ data }: { data: PodcastCopySection }) {
return createElement(
'section',
null,
createElement('h2', null, data.latestHeading),
createElement('span', null, data.latestEpisode),
createElement('span', null, data.seeAllEpisodes),
createElement('span', null, data.listenOnApp),
createElement('span', null, data.podcastCta),
createElement('span', null, data.episodePrefix),
createElement('span', null, data.fallbackEpisode),
)
}
describe('PodcastPage intro section driven from data', () => {
it('renders heading from data', () => {
const html = renderToStaticMarkup(createElement(IntroContent, { data: sectionData }))
expect(html).toContain('Logos Podcast')
expect(html).toContain('<h1')
})
it('renders eyebrow from data', () => {
const html = renderToStaticMarkup(createElement(IntroContent, { data: sectionData }))
expect(html).toContain('Media')
})
it('renders backToMedia from data', () => {
const html = renderToStaticMarkup(createElement(IntroContent, { data: sectionData }))
expect(html).toContain('Logos Media')
})
it('renders intro.description from data', () => {
const html = renderToStaticMarkup(createElement(IntroContent, { data: sectionData }))
expect(html).toContain('Logos Podcast is a biweekly interview show about building sovereign communities')
})
it('renders intro.hostedBy from data', () => {
const html = renderToStaticMarkup(createElement(IntroContent, { data: sectionData }))
expect(html).toContain('Hosted by Jarrad Hope.')
})
})
describe('PodcastPage podcasts list section driven from data', () => {
it('renders latestHeading from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsListContent, { data: sectionData }))
expect(html).toContain('Latest Episodes')
expect(html).toContain('<h2')
})
it('renders latestEpisode from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsListContent, { data: sectionData }))
expect(html).toContain('Latest episode')
})
it('renders seeAllEpisodes from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsListContent, { data: sectionData }))
expect(html).toContain('See all episodes')
})
it('renders podcastCta from data (sourced from pages.blog.podcasts.cta)', () => {
const html = renderToStaticMarkup(createElement(PodcastsListContent, { data: sectionData }))
expect(html).toContain('Logos podcast')
})
it('renders episodePrefix from data', () => {
const html = renderToStaticMarkup(createElement(PodcastsListContent, { data: sectionData }))
expect(html).toContain('Episode')
})
})
+29 -31
View File
@@ -1,9 +1,11 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { PodcastCopySection } from '@repo/content/schemas'
import { LogosMark } from '@acid-info/logos-ui'
import { ROUTES } from '@/constants/routes'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import ContentWidth from '@/components/layout/content-width'
import { ButtonArrowIcon } from '@/components/ui'
import { Link } from '@/i18n/navigation'
@@ -50,20 +52,11 @@ function PodcastIntro({ copy }: { copy: PodcastIntroCopy }) {
)
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.podcast' })
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.podcast,
})
}
const ROUTE = ROUTES.podcast
const findSection = createSectionFinder('podcast')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function LogosPodcastPage({
params,
@@ -75,9 +68,8 @@ export default async function LogosPodcastPage({
throw new Error(`LogosPodcastPage received non-active locale "${locale}"`)
}
const [t, blogT, podcasts] = await Promise.all([
getTranslations({ locale, namespace: 'pages.podcast' }),
getTranslations({ locale, namespace: 'pages.blog.podcasts' }),
const [page, podcasts] = await Promise.all([
getPageCopy(ROUTE, locale),
getLatestBlogPodcasts(20),
])
@@ -85,30 +77,36 @@ export default async function LogosPodcastPage({
throw new Error('Podcast page requires at least one podcast from blog API')
}
const data = findSection<PodcastCopySection>(
page.sections,
'podcastCopy',
'podcast.copy',
)
return (
<div className="overflow-hidden bg-accent-tan pb-10">
<PodcastIntro
copy={{
title: t('heading'),
description: t('intro.description'),
hostedBy: t('intro.hostedBy'),
backToMedia: t('backToMedia'),
title: data.heading,
description: data.intro.description,
hostedBy: data.intro.hostedBy,
backToMedia: data.backToMedia,
}}
/>
<PodcastsSection
podcasts={podcasts}
ctaHref={`${BLOG_ORIGIN}/podcasts`}
copy={{
heading: t('latestHeading'),
media: t('eyebrow'),
heading: data.latestHeading,
media: data.eyebrow,
heroTitle: podcasts[0].title,
heroDescription: podcasts[0].description,
latestEpisode: t('latestEpisode'),
seeAllEpisodes: t('seeAllEpisodes'),
listenOnApp: t('listenOnApp'),
cta: blogT('cta'),
episodePrefix: t('episodePrefix'),
fallbackEpisode: t('fallbackEpisode'),
latestEpisode: data.latestEpisode,
seeAllEpisodes: data.seeAllEpisodes,
listenOnApp: data.listenOnApp,
cta: data.podcastCta,
episodePrefix: data.episodePrefix,
fallbackEpisode: data.fallbackEpisode,
}}
/>
</div>
+23 -17
View File
@@ -1,23 +1,17 @@
import { getTranslations } from 'next-intl/server'
import { getPageCopy } from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import type { ResearchCopySection } from '@repo/content/schemas'
import ResearchPageView from '@/components/sections/research/research-page'
import { ROUTES } from '@/constants/routes'
import { createDefaultMetadata } from '@/lib/metadata'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'pages.research' })
return createDefaultMetadata({
title: t('title'),
description: t('description'),
locale,
path: ROUTES.research,
})
}
const ROUTE = ROUTES.research
const findSection = createSectionFinder('research')
export const generateMetadata = createPageMetadata(ROUTE)
export default async function ResearchPage({
params,
@@ -25,5 +19,17 @@ export default async function ResearchPage({
params: Promise<{ locale: string }>
}) {
const { locale } = await params
return <ResearchPageView locale={locale} />
if (!isActiveLocale(locale)) {
throw new Error(`ResearchPage received non-active locale "${locale}"`)
}
const page = await getPageCopy(ROUTE, locale)
const data = findSection<ResearchCopySection>(
page.sections,
'researchCopy',
'research.copy',
)
return <ResearchPageView data={data} locale={locale} />
}
@@ -1,11 +1,12 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import messages from '../../messages/en.json'
import { describe, expect, test } from 'vitest'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import nodeProgrammeContent from '../../../../content/pages/en/node-programme.json' with { type: 'json' }
const readNodeProgrammePageSource = () =>
readFileSync(
fileURLToPath(
@@ -14,21 +15,31 @@ const readNodeProgrammePageSource = () =>
'utf8'
)
type NodeProgrammeCopySection = {
componentType: 'nodeProgrammeCopy'
key: string
hero: { title: string; guideCta: string }
signup: { roles: string[] }
stack: { items: unknown[] }
useCases: { items: unknown[] }
}
describe('node programme page contract', () => {
test('uses the British English public route', () => {
expect(ROUTES.nodeProgramme).toBe('/node-programme')
})
test('has copy for the signup form and imported source sections', () => {
const section = nodeProgrammeContent.sections[0] as NodeProgrammeCopySection
// Hero wording can change freely; only require it to be present text.
expect(typeof messages.pages.nodeProgramme.hero.title).toBe('string')
expect(
messages.pages.nodeProgramme.hero.title.trim().length
).toBeGreaterThan(0)
expect(typeof section.hero.title).toBe('string')
expect(section.hero.title.trim().length).toBeGreaterThan(0)
// Roles are functional form values (submitted to the signup API), so they
// must stay non-empty strings. The set can grow, so we only require the
// core roles to be present rather than pinning the exact list.
const { roles } = messages.pages.nodeProgramme.signup
const { roles } = section.signup
expect(Array.isArray(roles)).toBe(true)
expect(roles.length).toBeGreaterThan(0)
roles.forEach((role) => {
@@ -38,20 +49,19 @@ describe('node programme page contract', () => {
expect(roles).toEqual(
expect.arrayContaining(['Node operator', 'Builder'])
)
expect(messages.pages.nodeProgramme.stack.items).toHaveLength(3)
expect(messages.pages.nodeProgramme.useCases.items).toHaveLength(5)
expect(section.stack.items).toHaveLength(3)
expect(section.useCases.items).toHaveLength(5)
})
test('links the hero CTA group to the node operator guide', () => {
const section = nodeProgrammeContent.sections[0] as NodeProgrammeCopySection
const pageSource = readNodeProgrammePageSource()
expect(messages.pages.nodeProgramme.hero.guideCta).toBe(
'Node operator guide'
)
expect(section.hero.guideCta).toBe('Node operator guide')
expect(EXTERNAL_URLS.nodeOperatorGuide).toBe(
'https://docs.logos.co/run-a-node'
)
expect(pageSource).toContain('href={EXTERNAL_URLS.nodeOperatorGuide}')
expect(pageSource).toContain("t('hero.guideCta')")
expect(pageSource).toContain('{data.hero.guideCta}')
})
})
@@ -1,81 +0,0 @@
'use client'
import { useState } from 'react'
/**
* Accordion list of FAQ Q&A pairs.
*
* Figma desktop 40009046:22322 (gap-12 between cards, w-464),
* mobile 40009046:22281 (gap-12 between cards, w-full).
*
* Each card: bg-gray-01, rounded-xl, p-3, gap-3 between question + answer.
* Question = text-eyebrow (Fira Code Medium uppercase 10px).
* Answer = text-mono-s (Fira Mono Regular 10px).
* Toggle = "+" / "-" right-aligned in question row.
*/
export interface FaqItem {
question: string
answer: string
}
interface FaqAccordionProps {
items: ReadonlyArray<FaqItem>
/** Index of the item open on first render. */
defaultOpen?: number
}
export function FaqAccordion({ items, defaultOpen = 0 }: FaqAccordionProps) {
const [openIndex, setOpenIndex] = useState<number>(defaultOpen)
return (
<ul className="flex w-full flex-col items-start gap-3">
{items.map((item, index) => {
const isOpen = index === openIndex
const panelId = `faq-panel-${index}`
const triggerId = `faq-trigger-${index}`
return (
<li key={index} className="w-full">
<button
type="button"
id={triggerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpenIndex(isOpen ? -1 : index)}
className="flex w-full cursor-pointer flex-col items-start rounded-xl bg-gray-01 p-3 text-left text-brand-dark-green"
>
<span className="text-eyebrow flex w-full items-center justify-between">
<span>{item.question}</span>
<span
aria-hidden="true"
className="transition-transform duration-300 ease-out"
style={{ transform: isOpen ? 'rotate(45deg)' : 'rotate(0deg)' }}
>
+
</span>
</span>
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
className="grid w-full transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<p
className={`text-mono-s w-full pt-3 normal-case transition-opacity duration-300 ease-out ${
isOpen ? 'opacity-100' : 'opacity-0'
}`}
>
{item.answer}
</p>
</div>
</div>
</button>
</li>
)
})}
</ul>
)
}
@@ -1,30 +0,0 @@
import { getTranslations } from 'next-intl/server'
import { DocsPageShell } from '@/components/sections/shared/docs-page-shell'
import { FaqAccordion, type FaqItem } from './faq-accordion'
/**
* FAQ page main content. Reuses the shared DocsPageShell (left ToC +
* right content column). See docs-page-shell.tsx for the Figma references.
*/
const ITEM_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8'] as const
export async function FaqSection() {
const t = await getTranslations('pages.faq')
const items: ReadonlyArray<FaqItem> = ITEM_KEYS.map((key) => ({
question: t(`items.${key}.question`),
answer: t(`items.${key}.answer`),
}))
return (
<DocsPageShell activeKey="faq">
<h1 className="text-eyebrow w-full text-brand-dark-green">
{t('heading')}
</h1>
<FaqAccordion items={items} />
</DocsPageShell>
)
}
@@ -1 +0,0 @@
export { FaqSection } from './faq-section'
@@ -0,0 +1,131 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Stub motion/animation dependencies to avoid browser-only APIs
vi.mock('@/components/motion/section-heading-reveal', () => ({
SectionHeadingReveal: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('@/components/motion/stack-card', () => ({
StackCard: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('next/image', () => ({
default: ({ src, alt }: { src: string; alt: string }) =>
createElement('img', { src, alt }),
}))
vi.mock('motion/react', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => children,
motion: {
div: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
},
}))
import AboutSection from '../about-section'
const problem = (title: string, facts: string[]) => ({
title,
subtitle: `${title} subtitle`,
body: `${title} body`,
facts,
})
const data = {
componentType: 'homeAbout' as const,
key: 'home.about',
heading: 'Civil society is in decline.',
headingMobile: 'Civil society is in decline on mobile.',
problems: {
debt: {
...problem('Debt', [
'debt fact alpha',
'debt fact beta',
'debt fact gamma',
'debt fact delta',
]),
factLinks: [
{
index: 1,
label: 'debt fact beta',
href: 'https://example.com/debt-fact',
},
],
},
surveillance: problem('Surveillance', [
'surveillance fact alpha',
'surveillance fact beta',
'surveillance fact gamma',
]),
corruption: problem('Corruption', [
'corruption fact alpha',
'corruption fact beta',
'corruption fact gamma',
]),
stagnation: problem('Stagnation', [
'stagnation fact alpha',
'stagnation fact beta',
]),
},
}
describe('AboutSection', () => {
it('renders heading from data props', () => {
const html = renderToStaticMarkup(createElement(AboutSection, { data }))
expect(html).toContain('Civil society is in decline.')
expect(html).toContain('Civil society is in decline on mobile.')
})
it('renders problem titles from data props', () => {
const html = renderToStaticMarkup(createElement(AboutSection, { data }))
expect(html).toContain('Debt')
expect(html).toContain('Surveillance')
expect(html).toContain('Corruption')
expect(html).toContain('Stagnation')
})
it('renders per-problem facts from data props', () => {
const html = renderToStaticMarkup(createElement(AboutSection, { data }))
// The accordion opens the first problem (debt) by default, so its facts
// surface in the rendered markup in their content array order.
expect(html).toContain('debt fact alpha')
expect(html).toContain('debt fact beta')
expect(html).toContain('debt fact gamma')
expect(html).toContain('debt fact delta')
// Facts are rendered in the order they appear in the content array.
expect(html.indexOf('debt fact alpha')).toBeLessThan(
html.indexOf('debt fact beta')
)
expect(html.indexOf('debt fact beta')).toBeLessThan(
html.indexOf('debt fact gamma')
)
expect(html.indexOf('debt fact gamma')).toBeLessThan(
html.indexOf('debt fact delta')
)
})
it('renders fact links from data props', () => {
const html = renderToStaticMarkup(createElement(AboutSection, { data }))
expect(html).toContain('href="https://example.com/debt-fact"')
expect(html).toContain('debt fact beta')
})
})
@@ -0,0 +1,64 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Stub heavy/animation imports to avoid browser-only deps in SSR tests
vi.mock('@/components/motion/section-heading-reveal', () => ({
SectionHeadingReveal: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('@/components/ui', () => ({
Button: ({
children,
href,
className,
}: {
children: ReactNode
href?: string
className?: string
}) => createElement('a', { href, className }, children),
ButtonArrowIcon: () => createElement('span', null, '→'),
}))
vi.mock('next/image', () => ({
default: ({ src, alt }: { src: string; alt: string }) =>
createElement('img', { src, alt }),
}))
vi.mock('@/components/layout/content-width', () => ({
default: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
import BuilderPortalSection from '../builder-portal-section'
const data = {
componentType: 'homeBuilderPortal' as const,
key: 'home.builderPortal',
title: 'Basecamp.',
description: 'Build something real.',
cta: 'Learn more',
featureChat: 'Load a basic chat app',
featureNode: 'Run a node',
featureTransactions: 'Execute private transactions',
}
describe('BuilderPortalSection', () => {
it('renders title, CTA, and description from data props', () => {
const html = renderToStaticMarkup(createElement(BuilderPortalSection, { data }))
expect(html).toContain('Basecamp.')
expect(html).toContain('Learn more')
expect(html).toContain('Build something real.')
})
})
@@ -0,0 +1,33 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Stub StackCard to avoid animation/browser-only dependencies
vi.mock('@/components/motion/stack-card', () => ({
StackCard: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
import DecideSection from '../decide-section'
const data = {
componentType: 'homeDecide' as const,
key: 'home.decide',
headline: 'We get to decide what comes next.',
headline2: 'Remain in the decline.',
headline3: 'Or exit and build the alternative.',
bodyParts: ['p1', 'p2', 'p3', 'p4'],
}
describe('DecideSection', () => {
it('renders headlines and body parts from data props', () => {
const html = renderToStaticMarkup(createElement(DecideSection, { data }))
expect(html).toContain('We get to decide what comes next.')
expect(html).toContain('p4')
})
})
@@ -0,0 +1,65 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Mock the navigation module to avoid Next.js router dependency in Node tests
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href }: { children: ReactNode; href: string }) =>
createElement('a', { href }, children),
}))
// Mock next/image to avoid Next.js image optimization in Node tests
vi.mock('next/image', () => ({
default: ({
src,
alt,
className,
}: {
src: string
alt: string
className?: string
}) => createElement('img', { src, alt, className }),
}))
// Mock logos-ui to avoid ESM/browser-only module issues
vi.mock('@acid-info/logos-ui', () => ({
LogosMark: ({ size }: { size?: number }) =>
createElement('span', { 'data-logos-mark': true, 'data-size': size }),
ButtonArrowIcon: () => createElement('span', { 'data-arrow-icon': true }),
}))
// Mock motion components to avoid animation dependencies
vi.mock('@/components/motion/section-heading-reveal', () => ({
SectionHeadingReveal: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
import FeatureCardsSection from '../feature-cards-section'
const path = { title: 'Build', body: 'b', cta: 'Start Building' }
const data = {
componentType: 'homeChoosePath' as const,
key: 'home.paths',
title: 'Choose your path',
kicker: 'k',
body: 'Take action.',
build: path,
operate: { title: 'Operate', body: 'b', cta: 'Run a Node' },
activism: { title: 'Organise', body: 'b', cta: 'Join the Movement' },
}
describe('FeatureCardsSection', () => {
it('renders path titles and CTAs from data props', () => {
const html = renderToStaticMarkup(
createElement(FeatureCardsSection, { data })
)
expect(html).toContain('Choose your path')
expect(html).toContain('Run a Node')
expect(html).toContain('Join the Movement')
})
})
@@ -0,0 +1,56 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Mock the navigation module to avoid Next.js router dependency in Node tests
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href }: { children: ReactNode; href: string }) =>
createElement('a', { href }, children),
}))
import SocialProofSection from '../social-proof-section'
const data = {
componentType: 'homeSocialProof' as const,
key: 'home.socialProof',
headline1: 'Logos is not for everyone.',
headline2: 'Logos is for people who are done waiting for permission.',
manifestoCta: 'Read the manifesto',
contributions: {
label: 'Contributors',
body: 'An open source community of builders, researchers, and technologists.',
},
nodeOperators: {
label: 'Node Operators',
body: 'A decentralised network of independent Node operators ensuring Logos is secure.',
},
circles: {
label: 'Circles',
body: 'Local chapters of activists and change seekers solving real world issues.',
},
winnableIssues: {
label: 'Winnable Issues',
body: 'Local issues that Circles identify and solve from privacy tech to community funding.',
},
}
const stats = {
contributions: '3,441',
contributors: '218',
repositories: '341',
}
describe('SocialProofSection', () => {
it('renders headlines and CTA from data props', () => {
const html = renderToStaticMarkup(
createElement(SocialProofSection, {
data,
stats,
winnableIssuesCount: '1',
})
)
expect(html).toContain('Logos is not for everyone.')
expect(html).toContain('Read the manifesto')
expect(html).toContain('Contributors')
})
})
@@ -0,0 +1,53 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Stub Button/ButtonArrowIcon to avoid UI library dependencies
vi.mock('@/components/ui', () => ({
Button: ({
children,
href,
}: {
children: ReactNode
href?: string
}) => createElement('a', { href }, children),
ButtonArrowIcon: () => createElement('span', null, '→'),
}))
// Stub ContentWidth to avoid layout dependencies
vi.mock('@/components/layout/content-width', () => ({
default: ({ children }: { children: ReactNode }) =>
createElement('div', null, children),
}))
// Stub next/image
vi.mock('next/image', () => ({
default: ({ src, alt }: { src: string; alt: string }) =>
createElement('img', { src, alt }),
}))
import StartBuildingSection from '../start-building-section'
const data = {
componentType: 'homeStartBuilding' as const,
key: 'home.startBuilding',
title: 'Start building.',
body: 'b',
cta: 'Get Started',
cardCta: 'Learn more',
lambdaPrize: 'Lambda Prize',
rfps: 'RFPs',
ideas: 'Starter Issues and Community Ideas Repo',
docs: 'View the Documentation',
}
describe('StartBuildingSection', () => {
it('renders title, CTA, and card labels from data props', () => {
const html = renderToStaticMarkup(
createElement(StartBuildingSection, { data })
)
expect(html).toContain('Start building.')
expect(html).toContain('Get Started')
expect(html).toContain('Lambda Prize')
})
})
@@ -0,0 +1,90 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// Stub motion/animation dependencies
vi.mock('@/components/motion/section-heading-reveal', () => ({
SectionHeadingReveal: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('@/components/motion/stack-card', () => ({
StackCard: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('@/components/ui', () => ({
DragScroll: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('next/image', () => ({
default: ({ src, alt }: { src: string; alt: string }) =>
createElement('img', { src, alt }),
}))
vi.mock('next/link', () => ({
default: ({
children,
href,
className,
}: {
children: ReactNode
href: string
className?: string
}) => createElement('a', { href, className }, children),
}))
import UseCasesSection from '../use-cases-section'
const card = (title: string) => ({ title, body: 'body text' })
const data = {
componentType: 'homeUseCases' as const,
key: 'home.useCases',
eyebrow: 'Use Cases',
headline: 'Privacy is the condition.',
headlineMobile: 'Privacy.',
lambda:
'Explore the applications funded through the <lambdaPrize>Lambda Prize</lambdaPrize>.',
lambdaMobile: 'Explore via <lambdaPrize>Lambda Prize</lambdaPrize>.',
secure: card('Secure, private communications.'),
money: card('Private, censorship-resistant money.'),
archives: card('Permanent, decentralised archives.'),
donations: card('Anonymous donations and mutual aid.'),
}
describe('UseCasesSection', () => {
it('renders eyebrow, card title, and the Lambda Prize link', () => {
const html = renderToStaticMarkup(createElement(UseCasesSection, { data }))
// Eyebrow appears in both mobile and desktop slots
expect(html).toContain('Use Cases')
// A card title renders
expect(html).toContain('Secure, private communications.')
// The Lambda Prize link renders as an anchor containing the inner text
expect(html).toContain('>Lambda Prize<')
expect(html).toContain('<a ')
// Highest-risk part of the refactor: the anchor must carry the correct
// href. ROUTES.lambdaPrize === '/lambda-prize' (apps/web/constants/routes.ts).
expect(html).toContain('href="/lambda-prize"')
})
})
@@ -1,4 +1,4 @@
import { getTranslations } from 'next-intl/server'
import type { HomeAboutSection } from '@repo/content/schemas'
import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal'
import { StackCard } from '@/components/motion/stack-card'
@@ -6,63 +6,51 @@ import CivilSocietyAccordion, {
type AccordionItem,
} from './civil-society-accordion'
export default async function AboutSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'home.about' })
function createFactLinks(
factLinks: HomeAboutSection['problems']['debt']['factLinks']
): AccordionItem['factLinks'] {
return Object.fromEntries(
(factLinks ?? []).map(({ index, label, href }) => [index, { label, href }])
)
}
export default function AboutSection({ data }: { data: HomeAboutSection }) {
const items: AccordionItem[] = [
{
key: 'debt',
title: t('problems.debt.title'),
subtitle: t('problems.debt.subtitle'),
body: t('problems.debt.body'),
facts: [
t('problems.debt.fact1'),
t('problems.debt.fact2'),
t('problems.debt.fact3'),
t('problems.debt.fact4'),
],
factLinks: {
1: {
label: '30% less wealth',
href: 'https://www.cam.ac.uk/research/news/boom-and-bust-millennials-arent-all-worse-off-than-baby-boomers-but-the-rich-poor-gap-is-widening',
},
},
title: data.problems.debt.title,
subtitle: data.problems.debt.subtitle,
body: data.problems.debt.body,
facts: data.problems.debt.facts,
factLinks: createFactLinks(data.problems.debt.factLinks),
image: '/images/home/figma-refresh/problem-debt.webp',
},
{
key: 'surveillance',
title: t('problems.surveillance.title'),
subtitle: t('problems.surveillance.subtitle'),
body: t('problems.surveillance.body'),
facts: [
t('problems.surveillance.fact1'),
t('problems.surveillance.fact2'),
t('problems.surveillance.fact3'),
],
factLinks: {},
title: data.problems.surveillance.title,
subtitle: data.problems.surveillance.subtitle,
body: data.problems.surveillance.body,
facts: data.problems.surveillance.facts,
factLinks: createFactLinks(data.problems.surveillance.factLinks),
image: '/images/home/figma-refresh/problem-surveillance.webp',
imageClassName: 'object-[50%_42%]',
},
{
key: 'corruption',
title: t('problems.corruption.title'),
subtitle: t('problems.corruption.subtitle'),
body: t('problems.corruption.body'),
facts: [
t('problems.corruption.fact1'),
t('problems.corruption.fact2'),
t('problems.corruption.fact3'),
],
factLinks: {},
title: data.problems.corruption.title,
subtitle: data.problems.corruption.subtitle,
body: data.problems.corruption.body,
facts: data.problems.corruption.facts,
factLinks: createFactLinks(data.problems.corruption.factLinks),
image: '/images/home/figma-refresh/problem-corruption.webp',
},
{
key: 'stagnation',
title: t('problems.stagnation.title'),
subtitle: t('problems.stagnation.subtitle'),
body: t('problems.stagnation.body'),
facts: [t('problems.stagnation.fact1'), t('problems.stagnation.fact2')],
factLinks: {},
title: data.problems.stagnation.title,
subtitle: data.problems.stagnation.subtitle,
body: data.problems.stagnation.body,
facts: data.problems.stagnation.facts,
factLinks: createFactLinks(data.problems.stagnation.factLinks),
image: '/images/home/figma-refresh/problem-stagnation.webp',
imageClassName: 'object-[50%_45%]',
},
@@ -77,8 +65,8 @@ export default async function AboutSection({ locale }: { locale: string }) {
>
<div className="mx-auto max-w-[1440px] px-3 pt-[112px] pb-[200px] lg:px-[130px] lg:pt-[112px] lg:pb-[291px]">
<SectionHeadingReveal className="mx-auto max-w-[853px] whitespace-pre-line text-center font-display text-[24px] leading-none tracking-[-0.72px] desktop:text-[36px] desktop:tracking-[-0.03em]">
<span className="desktop:hidden">{t('headingMobile')}</span>
<span className="hidden desktop:inline">{t('heading')}</span>
<span className="desktop:hidden">{data.headingMobile}</span>
<span className="hidden desktop:inline">{data.heading}</span>
</SectionHeadingReveal>
<div className="mt-[112px] lg:mt-[74px]">
<CivilSocietyAccordion items={items} />
@@ -1,6 +1,7 @@
import { getTranslations } from 'next-intl/server'
import Image from 'next/image'
import type { HomeBuilderPortalSection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal'
import { Button, ButtonArrowIcon } from '@/components/ui'
@@ -36,13 +37,11 @@ import { ROUTES } from '@/constants/routes'
// )
// }
export default async function BuilderPortalSection({
locale,
export default function BuilderPortalSection({
data,
}: {
locale: string
data: HomeBuilderPortalSection
}) {
const t = await getTranslations({ locale, namespace: 'home.builderPortal' })
return (
<section className="border-t border-brand-dark-green/10 bg-brand-off-white">
<ContentWidth className="desktop:pt-28 py-25 lg:pb-0">
@@ -53,7 +52,7 @@ export default async function BuilderPortalSection({
className="text-h2 desktop:w-[702px] relative z-[1] max-w-[702px] whitespace-pre-line text-brand-dark-green"
delay={0.08}
>
{t('title')}
{data.title}
</SectionHeadingReveal>
<Button
href={ROUTES.basecamp}
@@ -61,12 +60,12 @@ export default async function BuilderPortalSection({
icon={<ButtonArrowIcon />}
className="w-fit cursor-pointer transition-opacity hover:opacity-80"
>
{t('cta')}
{data.cta}
</Button>
</div>
<p className="text-mono-s desktop:w-[345px] whitespace-pre-line text-brand-dark-green">
{t('description')}
{data.description}
</p>
</div>
@@ -84,17 +83,17 @@ export default async function BuilderPortalSection({
{/* Temporarily hidden — re-enable to show the Basecamp feature boxes.
<div className="mt-3 grid gap-3 lg:grid-cols-3">
<BasecampFeature
label={t('featureChat')}
label={data.featureChat}
image="/images/home/figma-refresh/basecamp-chat.webp"
imageClassName="rotate-90 scale-125"
/>
<BasecampFeature
label={t('featureNode')}
label={data.featureNode}
image="/images/home/figma-refresh/basecamp-node.webp"
imageClassName="scale-125"
/>
<BasecampFeature
label={t('featureTransactions')}
label={data.featureTransactions}
image="/images/home/figma-refresh/basecamp-transactions.webp"
imageClassName="scale-125"
/>
@@ -1,4 +1,4 @@
import { getTranslations } from 'next-intl/server'
import type { HomeDecideSection } from '@repo/content/schemas'
import { StackCard } from '@/components/motion/stack-card'
@@ -10,9 +10,7 @@ function TabletLineBreak() {
return <br className="hidden min-[768px]:block" />
}
export default async function DecideSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'home.decide' })
export default function DecideSection({ data }: { data: HomeDecideSection }) {
return (
<StackCard
rise={180}
@@ -21,26 +19,26 @@ export default async function DecideSection({ locale }: { locale: string }) {
<div className="desktop:flex-row desktop:items-center desktop:justify-between desktop:gap-16 desktop:px-[131px] desktop:pt-[112px] desktop:pb-[224px] mx-auto flex max-w-[1440px] flex-col gap-10 px-3 pt-[88px] pb-[200px]">
<div className="flex flex-col items-start gap-8 min-[768px]:max-[1439px]:items-center min-[768px]:max-[1439px]:text-center min-[1440px]:max-w-[510px] min-[1440px]:gap-12">
<h2 className="w-full max-w-[510px] whitespace-pre-wrap font-display text-[40px] leading-none tracking-[-0.03em] [text-box-edge:cap_alphabetic] [text-box-trim:trim-both] [word-break:break-word] min-[768px]:max-[1439px]:text-[48px] min-[1440px]:text-[56px]">
<span className="block">{t('headline')}</span>
<span className="block">{data.headline}</span>
<span aria-hidden="true" className="block h-[1em]" />
<span>
{t('headline2')}
{data.headline2}
<TabletLineBreak />
<span className="min-[768px]:hidden"> </span>
<span className="text-[#F5F5EF]">{t('headline3')}</span>
<span className="text-[#F5F5EF]">{data.headline3}</span>
</span>
</h2>
<p className="w-full max-w-[510px] font-display text-[24px] leading-[1.1] tracking-[-0.24px] [word-break:normal] md:w-[510px]">
{t('bodyPart1')}
{data.bodyParts[0]}
<DesktopLineBreak />
<span className="min-[1440px]:hidden"> </span>
{t('bodyPart2')}
{data.bodyParts[1]}
<DesktopLineBreak />
<span className="min-[1440px]:hidden"> </span>
{t('bodyPart3')}
{data.bodyParts[2]}
<DesktopLineBreak />
<span className="min-[1440px]:hidden"> </span>
{t('bodyPart4')}
{data.bodyParts[3]}
</p>
</div>
@@ -1,7 +1,8 @@
'use client'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import type { HomeChoosePathSection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { LogosMark } from '@acid-info/logos-ui'
@@ -64,15 +65,17 @@ function PathCardView({ card }: { card: PathCard }) {
)
}
export default function FeatureCardsSection() {
const t = useTranslations('home.paths')
interface FeatureCardsSectionProps {
data: HomeChoosePathSection
}
export default function FeatureCardsSection({ data }: FeatureCardsSectionProps) {
const cards: PathCard[] = [
{
key: 'build',
title: t('build.title'),
body: t('build.body'),
cta: t('build.cta'),
title: data.build.title,
body: data.build.body,
cta: data.build.cta,
href: ROUTES.getStarted,
image: '/images/home/figma-refresh/path-build.webp',
overlay: 'bg-black/20',
@@ -80,9 +83,9 @@ export default function FeatureCardsSection() {
},
{
key: 'operate',
title: t('operate.title'),
body: t('operate.body'),
cta: t('operate.cta'),
title: data.operate.title,
body: data.operate.body,
cta: data.operate.cta,
href: ROUTES.nodeProgramme,
image: '/images/home/figma-refresh/path-operate.webp',
overlay: 'bg-black/45',
@@ -90,9 +93,9 @@ export default function FeatureCardsSection() {
},
{
key: 'activism',
title: t('activism.title'),
body: t('activism.body'),
cta: t('activism.cta'),
title: data.activism.title,
body: data.activism.body,
cta: data.activism.cta,
href: ROUTES.movement,
image: '/images/home/figma-refresh/path-activism.webp',
overlay: 'bg-black/45',
@@ -105,12 +108,12 @@ export default function FeatureCardsSection() {
<ContentWidth className="relative pt-3 pb-25 lg:pt-0 lg:pb-14">
<div className="mt-16 flex flex-col items-center gap-6 text-center lg:absolute lg:top-[112px] lg:left-1/2 lg:mt-0 lg:w-[940px] lg:max-w-[calc(100%-24px)] lg:-translate-x-1/2 lg:flex-row lg:items-start lg:justify-between lg:gap-8 lg:text-left">
<SectionHeadingReveal className="w-[351.5px] max-w-[calc(100vw-24px)] text-center font-display text-[24px] leading-none tracking-[-0.72px] text-brand-dark-green lg:w-auto lg:max-w-none lg:text-left lg:text-h2">
{t('title')}
{data.title}
</SectionHeadingReveal>
<div className="font-mono-body mx-auto flex w-[351.5px] max-w-[calc(100vw-24px)] flex-col gap-[13px] text-[10px] leading-[1.3] text-brand-dark-green lg:mx-0 lg:w-[226px] lg:gap-4 lg:text-mono-s">
<p>{t('kicker')}</p>
<p>{t('body')}</p>
<p>{data.kicker}</p>
<p>{data.body}</p>
</div>
</div>
@@ -1,69 +0,0 @@
import Image from 'next/image'
import type { FeaturedTextSection } from '@repo/content/schemas'
import { Button } from '@/components/ui'
const MOUNTAIN_ASPECT_RATIO = '1416 / 2283'
const MOUNTAIN_IMAGE_WIDTH_RATIO = '130.65%'
const MOUNTAIN_CLIP_PATH = 'inset(0 round 900px)'
type Props = {
data: FeaturedTextSection
}
export default function MountainSection({ data }: Props) {
return (
<section className="bg-brand-off-white px-3">
<div>
<div className="relative">
<div
className="relative z-10 overflow-hidden rounded-[900px] bg-brand-off-white"
style={{ aspectRatio: MOUNTAIN_ASPECT_RATIO }}
>
<div className="absolute inset-x-0 top-px bottom-0 overflow-hidden rounded-[900px]">
<Image
src="/images/home/mountain-landscape.webp"
alt=""
width={1850}
height={2313}
sizes="(min-width: 1440px) 1850px, 131vw"
className="absolute top-0 left-1/2 h-auto max-w-none -translate-x-1/2"
style={{ width: MOUNTAIN_IMAGE_WIDTH_RATIO }}
/>
</div>
</div>
<div
className="pointer-events-none absolute inset-0 z-20"
style={{ clipPath: MOUNTAIN_CLIP_PATH }}
>
<div className="absolute inset-x-0 bottom-[-100svh] top-[-100svh]">
<div className="sticky top-0 h-svh">
<div className="flex h-full flex-col items-center justify-center gap-10 px-4 text-center lg:gap-15 lg:px-0">
<h2 className="text-h1 w-full max-w-88 text-brand-off-white lg:max-w-full">
<span className="text-accent-light-blue">
{data.title.highlight}
</span>
<span>{` ${data.title.rest}`}</span>
</h2>
{data.cta ? (
<div className="pointer-events-auto">
<Button
href={data.cta.href}
className="bg-brand-off-white text-brand-dark-green backdrop-blur-none transition-opacity hover:opacity-80"
>
{data.cta.label}
</Button>
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
)
}
@@ -1,182 +0,0 @@
'use client'
import Image from 'next/image'
import type { FeaturedTextSection, GallerySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { Reveal } from '@/components/motion/reveal'
import { Button, DragScroll } from '@/components/ui'
/**
* Per-image desktop dimensions are positional — Figma's gallery has four
* cards at fixed widths/heights to match the photo crops. Editors keep the
* slot order stable; reordering would shuffle aspect ratios.
*/
const DESKTOP_DIMENSIONS = [
{ w: 407, h: 502 },
{ w: 534, h: 667 },
{ w: 466, h: 577 },
{ w: 596, h: 402 },
]
function MobileGalleryCard({
src,
alt,
caption,
date,
}: {
src: string
alt: string
caption: string
date: string
}) {
return (
<div className="w-[calc(100vw-24px)] max-w-[357px] shrink-0 snap-start">
<div className="h-[440px] w-full overflow-hidden rounded-[70px] bg-brand-dark-green/10">
<Image
src={src}
alt={alt}
width={356}
height={440}
className="h-full w-full object-cover object-center"
/>
</div>
{(caption || date) && (
<div className="text-eyebrow mt-2.5 flex items-center justify-between px-1 text-brand-dark-green/60">
<span>{caption}</span>
<span>{date}</span>
</div>
)}
</div>
)
}
function DesktopGalleryCard({
src,
alt,
w,
h,
caption,
date,
}: {
src: string
alt: string
w: number
h: number
caption: string
date: string
}) {
return (
<div className="shrink-0" style={{ width: `${w}px` }}>
<div
className="overflow-hidden rounded-[100px] bg-brand-dark-green/10"
style={{ height: `${h}px` }}
>
<Image
src={src}
alt={alt}
width={w}
height={h}
className="h-full w-full object-cover object-top"
/>
</div>
{(caption || date) && (
<div className="mt-2 flex gap-6 px-1">
<span className="text-eyebrow text-brand-dark-green/60">{caption}</span>
<span className="text-eyebrow text-brand-dark-green/60">{date}</span>
</div>
)}
</div>
)
}
type Props = {
/** Highlighted-word headline + CTA above the gallery. */
headline: FeaturedTextSection
/** Gallery items for the cards row below the headline. */
gallery: GallerySection
}
export default function ParallelSocietySection({ headline, gallery }: Props) {
const items = gallery.items.map((item, index) => ({
src: item.image.src,
alt: item.image.alt,
caption: item.caption ?? '',
date: item.date ?? '',
desktop: DESKTOP_DIMENSIONS[index] ?? DESKTOP_DIMENSIONS[0],
}))
return (
<section className="bg-brand-off-white shadow-[inset_0_1px_0_rgba(21,37,33,0.1)]">
<ContentWidth className="relative px-3 pt-33 pb-25 lg:pt-33 lg:pb-0">
<Reveal
amount={0.4}
delay={0.18}
viewportMargin="0px 0px -20% 0px"
className="mb-15"
>
<h2 className="text-h1 text-brand-dark-green text-center">
<span className="text-brand-dark-green">
{headline.title.highlight}
</span>{' '}
<span className="text-gray-04">{headline.title.rest}</span>
</h2>
</Reveal>
{headline.cta ? (
<div className="lg:mb-17.5 flex justify-center lg:mb-[111px]">
<Button
href={headline.cta.href}
variant="link"
className="cursor-pointer transition-opacity hover:opacity-70"
target={headline.cta.external ? '_blank' : undefined}
rel={headline.cta.external ? 'noopener noreferrer' : undefined}
>
{headline.cta.label}
</Button>
</div>
) : null}
</ContentWidth>
{/* Mobile Gallery — full width with internal scroll */}
<DragScroll
className="cursor-pointer overflow-x-auto px-3 pb-25 lg:hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="flex w-max snap-x snap-mandatory items-start gap-3">
{items.map((item) => (
<MobileGalleryCard
key={item.src}
src={item.src}
alt={item.alt}
caption={item.caption}
date={item.date}
/>
))}
</div>
</DragScroll>
{/* Desktop Gallery — full width with centered content and internal scroll */}
{/* pb-22 + Blog section py-3 (12px) = 100px gallery→Blog gap per Figma */}
<DragScroll
className="hidden overflow-x-auto lg:block lg:cursor-pointer lg:pb-22"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="flex items-start gap-3 px-3 mx-auto w-fit">
{items.map((item) => (
<DesktopGalleryCard
key={item.src}
src={item.src}
alt={item.alt}
w={item.desktop.w}
h={item.desktop.h}
caption={item.caption}
date={item.date}
/>
))}
</div>
</DragScroll>
</section>
)
}
@@ -1,6 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import type { HomeSocialProofSection } from '@repo/content/schemas'
import { Button } from '@/components/ui'
import { ROUTES } from '@/constants/routes'
@@ -57,30 +57,31 @@ function MobileStatRowView({
}
interface SocialProofSectionProps {
data: HomeSocialProofSection
stats: SocialProofStats
winnableIssuesCount: string
}
export default function SocialProofSection({
data,
stats,
winnableIssuesCount,
}: SocialProofSectionProps) {
const t = useTranslations('home.socialProof')
const cards = getSocialProofCards(stats, winnableIssuesCount, t)
const cards = getSocialProofCards(stats, winnableIssuesCount, data)
return (
<section className="relative z-[2] -mt-[60px] overflow-hidden rounded-t-[36px] bg-brand-off-white">
<div className="mx-auto max-w-[1440px] px-3">
<div className="flex flex-col items-center gap-6 pt-[112px] pb-[112px] text-center md:gap-9 md:pt-[72px] md:pb-[64px] lg:pt-[112px] lg:pb-[102px]">
<h2 className="w-[351.5px] max-w-[calc(100vw-50.5px)] text-center font-display text-[24px] leading-none tracking-[-0.72px] whitespace-pre-wrap text-brand-dark-green [text-box-edge:cap_alphabetic] [text-box-trim:trim-both] [word-break:break-word] md:w-full md:max-w-[853px] md:text-[56px] md:tracking-[-0.03em]">
<span className="block">{t('headline1')}</span>
<span className="block text-[#848e88]">{t('headline2')}</span>
<span className="block">{data.headline1}</span>
<span className="block text-[#848e88]">{data.headline2}</span>
</h2>
<Button
href={ROUTES.manifesto}
className="h-[31px] w-[151px] cursor-pointer rounded-[4px] px-3 py-2 transition-opacity hover:opacity-80 [&>span]:gap-1 md:h-auto md:w-auto md:rounded-xl md:[&>span]:gap-1.5"
>
{t('manifestoCta')}
{data.manifestoCta}
</Button>
</div>
@@ -1,6 +1,7 @@
import { getTranslations } from 'next-intl/server'
import Image from 'next/image'
import type { HomeStartBuildingSection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { Button, ButtonArrowIcon } from '@/components/ui'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
@@ -29,22 +30,20 @@ function SupportCardView({ card }: { card: SupportCard }) {
)
}
export default async function StartBuildingSection({
locale,
export default function StartBuildingSection({
data,
}: {
locale: string
data: HomeStartBuildingSection
}) {
const t = await getTranslations({ locale, namespace: 'home.startBuilding' })
const cards: SupportCard[] = [
{ title: t('lambdaPrize'), href: ROUTES.lambdaPrize, cta: t('cardCta') },
{ title: t('rfps'), href: ROUTES.rfps, cta: t('cardCta') },
{ title: data.lambdaPrize, href: ROUTES.lambdaPrize, cta: data.cardCta },
{ title: data.rfps, href: ROUTES.rfps, cta: data.cardCta },
{
title: t('ideas'),
title: data.ideas,
href: EXTERNAL_URLS.communityIdeas,
cta: t('cardCta'),
cta: data.cardCta,
},
{ title: t('docs'), href: EXTERNAL_URLS.docs, cta: t('cardCta') },
{ title: data.docs, href: EXTERNAL_URLS.docs, cta: data.cardCta },
]
return (
@@ -64,17 +63,17 @@ export default async function StartBuildingSection({
<div className="mx-auto mt-7 flex max-w-[320px] flex-col items-center gap-10 text-center lg:mt-[113px]">
<div className="flex flex-col items-center gap-6 lg:w-[320px]">
<h2 className="font-display text-[30px] leading-none tracking-[-0.03em] lg:text-[36px]">
{t('title')}
{data.title}
</h2>
<p className="font-sans text-[12px] leading-[1.2] lg:w-[254px]">
{t('body')}
{data.body}
</p>
</div>
<Button
href={ROUTES.getStarted}
className="cursor-pointer transition-opacity hover:opacity-80"
>
{t('cta')}
{data.cta}
</Button>
</div>
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { Fragment, type ReactNode } from 'react'
import { getTranslations } from 'next-intl/server'
import Image from 'next/image'
import Link from 'next/link'
@@ -10,20 +9,34 @@ import { DragScroll } from '@/components/ui'
import { ROUTES } from '@/constants/routes'
import { HOME_USE_CASE_CARDS } from '@/lib/homepage-section-data'
export default async function UseCasesSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'home.useCases' })
const renderLambdaPrizeText = (key: 'lambda' | 'lambdaMobile' = 'lambda') =>
t.rich(key, {
lambdaPrize: (chunks: ReactNode) => (
<Link
href={ROUTES.lambdaPrize}
className="cursor-pointer underline decoration-[1px] underline-offset-[3px] transition-opacity hover:opacity-70"
>
{chunks}
</Link>
),
})
import type { HomeUseCasesSection } from '@repo/content/schemas'
const LAMBDA_TAG = /<lambdaPrize>(.*?)<\/lambdaPrize>/
function renderLambdaPrizeText(value: string, href: string): ReactNode {
const match = value.match(LAMBDA_TAG)
if (!match) return value
const [full, inner] = match
const [before, after] = value.split(full)
return (
<Fragment>
{before}
<Link
href={href}
className="cursor-pointer underline decoration-[1px] underline-offset-[3px] transition-opacity hover:opacity-70"
>
{inner}
</Link>
{after}
</Fragment>
)
}
export default function UseCasesSection({
data,
}: {
data: HomeUseCasesSection
}) {
return (
<StackCard
rise={180}
@@ -34,22 +47,22 @@ export default async function UseCasesSection({ locale }: { locale: string }) {
<div className="flex w-full flex-col items-center px-3 lg:gap-12 lg:px-0">
<div className="flex max-w-[357px] flex-col items-center gap-8 text-center lg:hidden">
<p className="text-h4-sans whitespace-pre-line">
{t('headlineMobile')}
{data.headlineMobile}
</p>
<p className="text-mono-s whitespace-pre-line">
{renderLambdaPrizeText('lambdaMobile')}
{renderLambdaPrizeText(data.lambdaMobile, ROUTES.lambdaPrize)}
</p>
</div>
<p className="text-mono-s hidden w-full lg:block">{t('eyebrow')}</p>
<p className="text-mono-s hidden w-full lg:block">{data.eyebrow}</p>
<SectionHeadingReveal className="text-h3-serif hidden w-full whitespace-normal lg:block desktop:whitespace-pre-line">
{t('headline')}
{data.headline}
</SectionHeadingReveal>
</div>
<SectionHeadingReveal className="font-mono-body mt-[80px] text-center text-[10px] leading-[1.3] tracking-normal whitespace-nowrap lg:mt-0 lg:hidden">
{t('eyebrow')}
{data.eyebrow}
</SectionHeadingReveal>
<p className="text-mono-s hidden w-full lg:block">
{renderLambdaPrizeText()}
{renderLambdaPrizeText(data.lambda, ROUTES.lambdaPrize)}
</p>
</div>
@@ -60,11 +73,19 @@ export default async function UseCasesSection({ locale }: { locale: string }) {
className="relative h-[317px] w-[calc(100vw-24px)] max-w-[345px] shrink-0 snap-start overflow-hidden rounded-[12px] border border-brand-dark-green lg:flex lg:h-[317px] lg:w-auto lg:max-w-none lg:flex-col lg:justify-between lg:p-3"
>
<h3 className="absolute top-[15px] left-[15px] w-[249px] font-sans text-[24px] leading-[1.1] font-normal tracking-[-0.24px] lg:static lg:max-w-[249px] lg:w-auto">
{t(`${card.key}.title`)}
{
data[
card.key as 'secure' | 'money' | 'archives' | 'donations'
].title
}
</h3>
<div className="absolute right-[10px] bottom-[11px] left-[15px] flex items-end justify-between gap-3 lg:static">
<p className="font-mono-body w-[186px] text-[10px] leading-[1.3] lg:max-w-[186px]">
{t(`${card.key}.body`)}
{
data[
card.key as 'secure' | 'money' | 'archives' | 'donations'
].body
}
</p>
<div className="relative h-[118px] w-[96px] shrink-0 overflow-hidden">
<Image
@@ -0,0 +1,272 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { MovementCopySection } from '@repo/content/schemas'
// Stub next/image to avoid Next.js image optimization in Node tests
vi.mock('next/image', () => ({
default: ({
src,
alt,
className,
}: {
src: string
alt: string
className?: string
}) => createElement('img', { src, alt, className }),
}))
// Stub logos-ui to avoid ESM/browser-only issues
vi.mock('@acid-info/logos-ui', () => ({
LogosMark: ({ size }: { size?: number }) =>
createElement('span', { 'data-logos-mark': true, 'data-size': size }),
}))
// Stub navigation to avoid Next.js router dependency
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href }: { children: ReactNode; href: string }) =>
createElement('a', { href }, children),
}))
// Stub icon-mask to avoid SVG/browser-only dependency
vi.mock('@/components/icons/icon-mask', () => ({
IconMask: ({ src, className }: { src: string; className?: string }) =>
createElement('span', { 'data-icon-mask': src, className }),
}))
// Stub circles-map to avoid heavy map dependencies
vi.mock('@/components/sections/circles/circles-map', () => ({
default: () => createElement('div', { 'data-testid': 'circles-map' }),
}))
// Stub shared sections that depend on circle records
vi.mock('@/components/sections/shared/upcoming-events-section', () => ({
UpcomingEventsSection: () =>
createElement('div', { 'data-testid': 'upcoming-events-section' }),
}))
vi.mock('@/components/sections/shared/circle-resources-section', () => ({
CircleResourcesSection: () =>
createElement('div', { 'data-testid': 'circle-resources-section' }),
}))
import { MovementPageView } from '../movement-page'
const fixture: MovementCopySection = {
componentType: 'movementCopy',
key: 'movement.copy',
heading: 'Movement',
hero: {
title: 'Logos Movement',
kicker: 'Local action',
body: 'Logos is a movement of builders, activists, and community organisers rebuilding civil society.',
primaryCta: 'Join a circle',
secondaryCta: 'Start a new circle',
},
intro: {
titleLine1: 'The Movement',
titleLine2: 'to Rebuild Society',
body: 'Technology alone will not save us. Join activists working to rebuild civil society.',
},
actions: {
activism: {
title: 'Activism',
body: 'Join a Circle to meet fellow activists.',
cta: 'Take action',
},
coalition: {
title: 'Coalition',
body: 'Collaborate with organisations.',
cta: 'Join the coalition',
},
building: {
title: 'Impact Building',
body: 'Build tech that creates real impact.',
cta: 'Build for impact',
},
},
campaign: {
eyebrow: 'Movement',
kicker: 'Activism starts local',
title: 'Join an Activist Circle',
body: 'Logos Circles are self-organised groups.',
primaryCta: 'Join an upcoming circle',
secondaryCta: 'Propose a new circle',
tertiaryCta: 'Join the community',
},
find: {
title: 'Find a Circle near you.',
body: 'Activist leaders are organising around the world.',
cta: 'Find a circle near you',
},
activismSection: {
title: 'Activism in Action',
body: 'Local Circles are solving winnable issues every week.',
cta: 'Submit an issue',
},
events: {
title: 'Upcoming Events',
body: 'Ideas from our community.',
cta: 'See full calendar',
day1: { date: 'January 21', weekday: 'Wednesday' },
day2: { date: 'January 22', weekday: 'Thursday' },
day3: { date: 'January 23', weekday: 'Friday' },
card: {
title: 'Logos Circle Florianopolis #2',
time: '12:00 PM - 6:00 PM',
timezone: 'GMT+1',
location: 'Florianopolis, Santa Catarina',
hosts: 'By InfinityBase',
},
},
involved: {
title: 'Get involved.',
body: 'Chat with fellow activists every week.',
primaryCta: 'Join the conversation on X',
secondaryCta: 'Join the community',
},
coalition: {
title: 'Join the Coalition.',
body: 'It takes a village.',
cta: 'Join the coalition',
},
builder: {
title: 'Become an Activist Builder',
body: 'Every movement needs its tools.',
primaryCta: 'Build for impact',
secondaryCta: 'Build on Logos stack',
feature: {
city: 'Benin',
title: 'Regenerative Social DeFi Fundraising Platform',
cta: 'View issue',
},
details: {
problem: {
label: 'Problem',
body: 'Grassroots projects struggle to access funding.',
},
solution: {
label: 'Solution',
body: 'Build a regenerative fundraising platform.',
},
stack: {
label: 'Stack',
body: 'React, Node.js, Aave/Morpho.',
},
},
},
resources: {
titleLine1: 'Circles',
titleLine2: 'Resources',
body: 'Ideas from our community.',
cta: 'Help Center',
rows: {
start: {
number: '01',
title: 'Start your own Circle',
body: 'Advanced privacy.',
cta: 'Learn More',
},
forum: {
number: '02',
title: 'Forum',
body: 'Advanced privacy.',
cta: 'Learn More',
},
discord: {
number: '03',
title: 'Discord',
body: 'Advanced privacy.',
cta: 'Learn More',
},
},
},
}
const circlesSettings = {
eventsSection: { title: 'Events', body: 'Events body', cta: 'See events' },
resourcesSection: {
title: 'Resources',
body: 'Resources body',
cta: 'See resources',
},
}
describe('MovementPageView content-driven copy', () => {
it('renders hero body from data prop', () => {
const html = renderToStaticMarkup(
createElement(MovementPageView, {
data: fixture,
circlesSettings: circlesSettings as never,
mapMarkers: [],
upcomingEvents: [],
initiatives: [],
resources: [],
locale: 'en',
}),
)
expect(html).toContain(
'Logos is a movement of builders, activists, and community organisers rebuilding civil society.',
)
})
it('renders campaign title from data prop', () => {
const html = renderToStaticMarkup(
createElement(MovementPageView, {
data: fixture,
circlesSettings: circlesSettings as never,
mapMarkers: [],
upcomingEvents: [],
initiatives: [],
resources: [],
locale: 'en',
}),
)
expect(html).toContain('Join an Activist Circle')
})
it('renders builder title from data prop', () => {
const html = renderToStaticMarkup(
createElement(MovementPageView, {
data: fixture,
circlesSettings: circlesSettings as never,
mapMarkers: [],
upcomingEvents: [],
initiatives: [],
resources: [],
locale: 'en',
}),
)
expect(html).toContain('Become an Activist Builder')
})
it('renders activismSection title from data prop', () => {
const html = renderToStaticMarkup(
createElement(MovementPageView, {
data: fixture,
circlesSettings: circlesSettings as never,
mapMarkers: [],
upcomingEvents: [],
initiatives: [],
resources: [],
locale: 'en',
}),
)
expect(html).toContain('Activism in Action')
})
it('renders find.title from data prop', () => {
const html = renderToStaticMarkup(
createElement(MovementPageView, {
data: fixture,
circlesSettings: circlesSettings as never,
mapMarkers: [],
upcomingEvents: [],
initiatives: [],
resources: [],
locale: 'en',
}),
)
expect(html).toContain('Find a Circle near you.')
})
})
@@ -1,12 +1,14 @@
import Image from 'next/image'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { Cta, LambdaBadge, SectionHeader, movementImages } from './atoms'
import type { Translate } from './types'
export function ActionCardsSection({ t }: { t: Translate }) {
const cards = [
type ActionKey = keyof MovementCopySection['actions']
export function ActionCardsSection({ data }: { data: MovementCopySection }) {
const cards: { key: ActionKey; image: string; href: string }[] = [
{
key: 'activism',
image: movementImages.activism,
@@ -29,12 +31,12 @@ export function ActionCardsSection({ t }: { t: Translate }) {
<SectionHeader
title={
<>
{t('intro.titleLine1')}
{data.intro.titleLine1}
<br />
{t('intro.titleLine2')}
{data.intro.titleLine2}
</>
}
description={t('intro.body')}
description={data.intro.body}
className="pb-10 md:pt-6 md:pb-15"
titleClassName="max-w-[244px]"
descriptionClassName="md:col-span-4"
@@ -59,17 +61,17 @@ export function ActionCardsSection({ t }: { t: Translate }) {
<div className="flex items-center gap-1.5">
<LambdaBadge size={23} circle />
<h3 className="text-subhead-sans">
{t(`actions.${card.key}.title`)}
{data.actions[card.key].title}
</h3>
</div>
</div>
<p className="text-mono-s flex-1">
{t(`actions.${card.key}.body`)}
{data.actions[card.key].body}
</p>
</div>
<Cta
href={card.href}
label={t(`actions.${card.key}.cta`)}
label={data.actions[card.key].cta}
tone="light"
className="mx-1.5 mt-auto w-[calc(100%-12px)]"
/>
@@ -1,11 +1,11 @@
import Image from 'next/image'
import { type CircleInitiative } from '@repo/content/loaders'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { ArrowIcon } from '@/components/sections/circles/_helpers'
import { LambdaBadge, SectionHeader } from './atoms'
import type { Translate } from './types'
function IssueCard({ initiative }: { initiative: CircleInitiative }) {
const city = initiative.locationLabel.split(',')[0]
@@ -63,10 +63,10 @@ function IssueCard({ initiative }: { initiative: CircleInitiative }) {
}
export function ActivismSection({
t,
data,
initiatives,
}: {
t: Translate
data: MovementCopySection
initiatives: CircleInitiative[]
}) {
const cards = initiatives.slice(0, 3)
@@ -74,8 +74,8 @@ export function ActivismSection({
return (
<section className="border-t border-brand-dark-green/10 bg-brand-off-white pb-10 text-brand-dark-green md:pb-25">
<SectionHeader
title={t('activism.title')}
description={t('activism.body')}
title={data.activismSection.title}
description={data.activismSection.body}
className="pb-10 md:pb-19.5"
/>
<ContentWidth className="overflow-x-auto">
@@ -1,13 +1,15 @@
import Image from 'next/image'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { CenterCtaSection, Cta, LambdaBadge, movementImages } from './atoms'
import type { Translate } from './types'
export function BuilderSection({ t }: { t: Translate }) {
const details = ['problem', 'solution', 'stack']
type DetailKey = keyof MovementCopySection['builder']['details']
export function BuilderSection({ data }: { data: MovementCopySection }) {
const details: DetailKey[] = ['problem', 'solution', 'stack']
return (
<section
@@ -15,17 +17,17 @@ export function BuilderSection({ t }: { t: Translate }) {
className="bg-brand-off-white pt-0 pb-10 text-brand-dark-green md:pb-25"
>
<CenterCtaSection
title={t('builder.title')}
body={t('builder.body')}
title={data.builder.title}
body={data.builder.body}
cta={
<div className="flex flex-wrap justify-center gap-2">
<Cta
href={ROUTES.activistBuilder}
label={t('builder.primaryCta')}
label={data.builder.primaryCta}
/>
<Cta
href={ROUTES.buildersHub}
label={t('builder.secondaryCta')}
label={data.builder.secondaryCta}
tone="secondary"
/>
</div>
@@ -48,23 +50,23 @@ export function BuilderSection({ t }: { t: Translate }) {
<div className="flex items-center gap-1.5">
<LambdaBadge size={15} tone="light" circle />
<p className="text-subhead-serif">
{t('builder.feature.city')}
{data.builder.feature.city}
</p>
</div>
<Cta
href={EXTERNAL_URLS.circlesWinnableIssueBenin}
label={t('builder.feature.cta')}
label={data.builder.feature.cta}
tone="light"
className="md:hidden"
/>
</div>
<h3 className="text-subhead-sans mx-auto max-w-[220px] text-center md:mx-0 md:text-left">
{t('builder.feature.title')}
{data.builder.feature.title}
</h3>
<div className="hidden w-fit md:block">
<Cta
href={EXTERNAL_URLS.circlesWinnableIssueBenin}
label={t('builder.feature.cta')}
label={data.builder.feature.cta}
tone="light"
/>
</div>
@@ -76,10 +78,10 @@ export function BuilderSection({ t }: { t: Translate }) {
className="grid gap-3 border-t border-brand-off-white/50 pt-1.5 md:grid-cols-2"
>
<p className="text-eyebrow">
{t(`builder.details.${detail}.label`)}
{data.builder.details[detail].label}
</p>
<p className="text-mono-s">
{t(`builder.details.${detail}.body`)}
{data.builder.details[detail].body}
</p>
</div>
))}
@@ -1,13 +1,13 @@
import Image from 'next/image'
import { LogosMark } from '@acid-info/logos-ui'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { Cta, movementImages } from './atoms'
import type { Translate } from './types'
export function CampaignSection({ t }: { t: Translate }) {
export function CampaignSection({ data }: { data: MovementCopySection }) {
return (
<section
id="activist-circle"
@@ -17,28 +17,28 @@ export function CampaignSection({ t }: { t: Translate }) {
<div className="flex min-h-[462px] flex-col justify-between p-3 md:min-h-[462px]">
<div className="flex gap-22">
<LogosMark size={7} />
<p className="text-eyebrow w-[185px]">{t('campaign.eyebrow')}</p>
<p className="text-eyebrow w-[185px]">{data.campaign.eyebrow}</p>
</div>
<div className="mx-auto flex max-w-[678px] flex-col items-center gap-10 text-center">
<div className="flex max-w-[320px] flex-col items-center gap-6">
<div className="flex flex-col items-center gap-3">
<p className="text-eyebrow">{t('campaign.kicker')}</p>
<h2 className="text-h3-sans">{t('campaign.title')}</h2>
<p className="text-eyebrow">{data.campaign.kicker}</p>
<h2 className="text-h3-sans">{data.campaign.title}</h2>
</div>
<p className="text-caption-sans font-medium">
{t('campaign.body')}
{data.campaign.body}
</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Cta href="#circles-map" label={t('campaign.primaryCta')} />
<Cta href="#circles-map" label={data.campaign.primaryCta} />
<Cta
href={ROUTES.activistLeaderSteward}
label={t('campaign.secondaryCta')}
label={data.campaign.secondaryCta}
tone="secondary"
/>
<Cta
href={EXTERNAL_URLS.discord}
label={t('campaign.tertiaryCta')}
label={data.campaign.tertiaryCta}
tone="tertiary"
/>
</div>
@@ -1,28 +1,28 @@
import Image from 'next/image'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { Cta, movementImages } from './atoms'
import type { Translate } from './types'
export function GetInvolvedSection({ t }: { t: Translate }) {
export function GetInvolvedSection({ data }: { data: MovementCopySection }) {
return (
<section className="bg-brand-off-white py-10 text-brand-dark-green">
<ContentWidth className="grid gap-3 md:grid-cols-2">
<div className="flex h-[270px] flex-col items-center justify-center gap-8 border border-brand-dark-green p-4 text-center md:h-[500px] md:gap-10">
<div className="flex max-w-[337px] flex-col items-center gap-3 md:max-w-[380px]">
<h2 className="text-subhead-sans">{t('involved.title')}</h2>
<p className="text-mono-s">{t('involved.body')}</p>
<h2 className="text-subhead-sans">{data.involved.title}</h2>
<p className="text-mono-s">{data.involved.body}</p>
</div>
<div className="flex flex-col items-center gap-2 md:flex-row">
<Cta
href={EXTERNAL_URLS.twitter}
label={t('involved.primaryCta')}
label={data.involved.primaryCta}
/>
<Cta
href={EXTERNAL_URLS.discord}
label={t('involved.secondaryCta')}
label={data.involved.secondaryCta}
tone="secondary"
/>
</div>
@@ -41,14 +41,14 @@ export function GetInvolvedSection({ t }: { t: Translate }) {
<div className="absolute inset-0 bg-black/20" />
<div className="relative flex max-w-[432px] flex-col items-center gap-5">
<div className="flex flex-col items-center gap-3">
<h2 className="text-subhead-sans">{t('coalition.title')}</h2>
<h2 className="text-subhead-sans">{data.coalition.title}</h2>
<p className="text-mono-s max-w-[236px] md:max-w-[380px]">
{t('coalition.body')}
{data.coalition.body}
</p>
</div>
<Cta
href={ROUTES.coalitionPartner}
label={t('coalition.cta')}
label={data.coalition.cta}
tone="light"
/>
</div>
@@ -1,13 +1,13 @@
import Image from 'next/image'
import type { MovementCopySection } from '@repo/content/schemas'
import ContentWidth from '@/components/layout/content-width'
import { LambdaBadge, movementImages } from './atoms'
import type { Translate } from './types'
import { Cta, LambdaBadge, movementImages } from './atoms'
export function HeroSection({ t }: { t: Translate }) {
export function HeroSection({ data }: { data: MovementCopySection }) {
return (
<section className="relative h-[494px] overflow-hidden bg-brand-off-white pt-6 text-brand-dark-green md:h-[427px]">
<section className="relative h-[494px] overflow-hidden bg-brand-off-white pt-6 text-brand-dark-green md:h-[579px]">
<ContentWidth className="relative h-full">
<div className="relative h-[75px] w-[107px] overflow-hidden bg-gray-01">
<Image
@@ -21,15 +21,37 @@ export function HeroSection({ t }: { t: Translate }) {
<div className="absolute inset-0 bg-black/30" />
</div>
{data.hero.primaryCta ? (
<Cta
href="#circles-map"
label={data.hero.primaryCta}
tone="tertiary"
className="absolute top-6 left-1/2 translate-x-[8px] md:translate-x-[6px]"
/>
) : null}
{data.hero.kicker ? (
<p className="text-mono-s absolute top-6 left-[calc(83.333%+2px)] hidden w-[226px] md:block">
{data.hero.kicker}
</p>
) : null}
<div className="absolute top-[130px] left-1/2 flex w-[348px] -translate-x-1/2 items-center justify-center gap-2.5 md:w-auto">
<LambdaBadge size={42.503} />
<h1 className="font-display text-[36px] leading-none tracking-[-0.03em] whitespace-nowrap md:text-[56px]">
{t('hero.title')}
{data.hero.title}
</h1>
</div>
<div className="absolute top-[274px] left-1/2 w-[369px] max-w-[calc(100%-24px)] -translate-x-1/2 md:top-[256px] md:w-[min(422px,calc(50vw-18px))] md:max-w-none md:translate-x-[6px] lg:w-[422px]">
<p className="text-mono-s">{t('hero.body')}</p>
<div className="absolute top-[274px] left-1/2 w-[369px] max-w-[calc(100%-24px)] -translate-x-1/2 md:top-[337px] md:w-[min(422px,calc(50vw-18px))] md:max-w-none md:translate-x-[6px] lg:w-[422px]">
<p className="text-mono-s">{data.hero.body}</p>
{data.hero.secondaryCta ? (
<Cta
href="#activist-circle"
label={data.hero.secondaryCta}
tone="tertiary"
className="mt-10 ml-[191px] md:ml-0"
/>
) : null}
</div>
</ContentWidth>
</section>
@@ -1,8 +1,7 @@
/**
* Shared types for the Movement page sections. Each section file imports the
* slice it needs (e.g. the `Translate` function or `CtaProps`).
* slice it needs (e.g. `CtaProps`).
*/
export type Translate = (key: string) => string
export type CtaTone = 'primary' | 'secondary' | 'tertiary' | 'light' | 'link'
@@ -2,7 +2,11 @@ import {
type CircleInitiative,
type CircleResource,
} from '@repo/content/loaders'
import type { CirclesSettings, Language } from '@repo/content/schemas'
import type {
CirclesSettings,
Language,
MovementCopySection,
} from '@repo/content/schemas'
import CirclesMap from '@/components/sections/circles/circles-map'
import type {
@@ -19,10 +23,9 @@ import { EventsSection } from './_sections/events'
import { GetInvolvedSection } from './_sections/get-involved'
import { HeroSection } from './_sections/hero'
import { ResourcesSection } from './_sections/resources'
import type { Translate } from './_sections/types'
export function MovementPageView({
t,
data,
circlesSettings,
mapMarkers,
upcomingEvents,
@@ -30,7 +33,7 @@ export function MovementPageView({
resources,
locale,
}: {
t: Translate
data: MovementCopySection
circlesSettings: CirclesSettings
mapMarkers: ActiveCircleMarker[]
upcomingEvents: ActiveCircleUpcomingEvent[]
@@ -40,17 +43,17 @@ export function MovementPageView({
}) {
const findCta = (
<CenterCtaSection
title={t('find.title')}
body={t('find.body')}
title={data.find.title}
body={data.find.body}
className="pb-19"
/>
)
return (
<>
<HeroSection t={t} />
<ActionCardsSection t={t} />
<CampaignSection t={t} />
<HeroSection data={data} />
<ActionCardsSection data={data} />
<CampaignSection data={data} />
<div className="flex flex-col">
<div>{findCta}</div>
<div id="circles-map">
@@ -62,14 +65,14 @@ export function MovementPageView({
/>
</div>
</div>
<ActivismSection t={t} initiatives={initiatives} />
<ActivismSection data={data} initiatives={initiatives} />
<EventsSection
settings={circlesSettings}
events={upcomingEvents}
locale={locale}
/>
<GetInvolvedSection t={t} />
<BuilderSection t={t} />
<GetInvolvedSection data={data} />
<BuilderSection data={data} />
<ResourcesSection settings={circlesSettings} resources={resources} />
</>
)
@@ -0,0 +1,173 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { ResearchCopySection } from '@repo/content/schemas'
// 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 ContentWidth to avoid layout dependencies
vi.mock('@/components/layout/content-width', () => ({
default: ({ children, className }: { children: React.ReactNode; className?: string }) =>
createElement('div', { className }, children),
}))
// Stub @/components/ui to avoid @acid-info/logos-ui dependency
vi.mock('@/components/ui', () => ({
ButtonArrowIcon: () => createElement('span', {}, '→'),
}))
// Stub @/i18n/navigation to avoid Next.js router dependency
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) =>
createElement('a', { href, className }, children),
}))
import { HeroSection } from '../_sections/hero-section'
import { ResourcesSection } from '../_sections/resources-section'
const sectionData: ResearchCopySection = {
componentType: 'researchCopy',
key: 'research.copy',
hero: {
kicker: 'Research and development across the Logos Network and IFT.',
title: 'Logos\nResearch',
description: 'Logos Research conducts innovative exploration within the Logos ecosystem and wider IFT.',
ctas: [
{ label: 'Specs/RFCs', href: 'https://lip.logos.co/' },
{ label: 'Research Forum', href: 'https://forum.research.logos.co/' },
],
},
overview: {
title: 'Overview',
body: { paragraphs: { p1: 'Paragraph one.' } },
cta: { label: 'Join the Research Forum', href: 'https://forum.research.logos.co/' },
},
resources: {
title: 'Resources',
learnMore: 'Learn more',
items: [
{ number: '01', label: 'Specs/RFCs', href: 'https://lip.logos.co/' },
{ number: '02', label: 'Research Forum', href: 'https://forum.research.logos.co/' },
{ number: '03', label: 'Discord', href: 'https://discord.gg/PQFdubGt6d', cta: 'Join server' },
],
},
contribute: {
title: 'Contribute',
copy: {
howTitle: 'How to contribute',
contact: 'Get in touch with us by <discord></discord>, <forum></forum>, or <github></github>.',
jobs: 'Also, see <jobs></jobs>.',
links: { discord: 'joining our Discord', forum: 'opening a thread in our forum', github: 'opening issues / PRs on GitHub', jobs: 'IFT current job openings' },
whatTitle: 'What to contribute',
whatBody: 'We are interested in both research and code contributions.',
codeIntro: 'For code contributions, see our "good first issue" lists:',
codeLinks: [{ label: 'nim-libp2p', href: 'https://github.com/vacp2p/nim-libp2p' }],
},
},
}
describe('ResearchPage hero section driven from data', () => {
it('renders the kicker from data', () => {
const html = renderToStaticMarkup(
createElement(HeroSection, {
kicker: sectionData.hero.kicker,
title: sectionData.hero.title,
description: sectionData.hero.description,
ctas: sectionData.hero.ctas,
})
)
expect(html).toContain('Research and development across the Logos Network and IFT.')
})
it('renders the title from data', () => {
const html = renderToStaticMarkup(
createElement(HeroSection, {
kicker: sectionData.hero.kicker,
title: sectionData.hero.title,
description: sectionData.hero.description,
ctas: sectionData.hero.ctas,
})
)
expect(html).toContain('Logos')
expect(html).toContain('Research')
expect(html).toContain('<h1')
})
it('renders the description from data', () => {
const html = renderToStaticMarkup(
createElement(HeroSection, {
kicker: sectionData.hero.kicker,
title: sectionData.hero.title,
description: sectionData.hero.description,
ctas: sectionData.hero.ctas,
})
)
expect(html).toContain('Logos Research conducts innovative exploration')
})
it('renders CTA labels from data', () => {
const html = renderToStaticMarkup(
createElement(HeroSection, {
kicker: sectionData.hero.kicker,
title: sectionData.hero.title,
description: sectionData.hero.description,
ctas: sectionData.hero.ctas,
})
)
expect(html).toContain('Specs/RFCs')
expect(html).toContain('Research Forum')
})
})
describe('ResearchPage resources section driven from data', () => {
it('renders the section title from data', () => {
const html = renderToStaticMarkup(
createElement(ResourcesSection, {
title: sectionData.resources.title,
learnMoreLabel: sectionData.resources.learnMore,
items: sectionData.resources.items,
})
)
expect(html).toContain('Resources')
expect(html).toContain('<h2')
})
it('renders resource item labels from data', () => {
const html = renderToStaticMarkup(
createElement(ResourcesSection, {
title: sectionData.resources.title,
learnMoreLabel: sectionData.resources.learnMore,
items: sectionData.resources.items,
})
)
expect(html).toContain('Specs/RFCs')
expect(html).toContain('Research Forum')
expect(html).toContain('Discord')
})
it('renders per-item CTA override from data', () => {
const html = renderToStaticMarkup(
createElement(ResourcesSection, {
title: sectionData.resources.title,
learnMoreLabel: sectionData.resources.learnMore,
items: sectionData.resources.items,
})
)
expect(html).toContain('Join server')
})
it('falls back to learnMore label when item has no cta', () => {
const html = renderToStaticMarkup(
createElement(ResourcesSection, {
title: sectionData.resources.title,
learnMoreLabel: sectionData.resources.learnMore,
items: sectionData.resources.items,
})
)
expect(html).toContain('Learn more')
})
})
@@ -1,45 +1,41 @@
import type { ReactNode } from 'react'
import { getTranslations } from 'next-intl/server'
import type { ResearchCopySection } from '@repo/content/schemas'
import { EXTERNAL_URLS } from '@/constants/routes'
import { renderTaggedText } from '@/lib/render-tagged-text'
import { TextLink } from './_sections/atoms'
import { ContributeSection } from './_sections/contribute-section'
import { HeroSection } from './_sections/hero-section'
import { OverviewSection } from './_sections/overview-section'
import { ResourcesSection } from './_sections/resources-section'
import type { ContributeCopy, LinkItem, ResourceItem } from './_sections/types'
import type { ContributeCopy } from './_sections/types'
const overviewLink =
(href: string) =>
(chunks: ReactNode): ReactNode => (
<TextLink href={href} label="">
{chunks}
</TextLink>
)
const overviewLinkRenderers = {
logos: (text: string) => <TextLink href={EXTERNAL_URLS.logosHome} label="">{text}</TextLink>,
ift: (text: string) => <TextLink href={EXTERNAL_URLS.ift} label="">{text}</TextLink>,
units: (text: string) => <TextLink href={EXTERNAL_URLS.researchServiceUnits} label="">{text}</TextLink>,
incubator: (text: string) => <TextLink href={EXTERNAL_URLS.researchIncubatorProjects} label="">{text}</TextLink>,
research: (text: string) => <TextLink href={EXTERNAL_URLS.researchApplied} label="">{text}</TextLink>,
specs: (text: string) => <TextLink href={EXTERNAL_URLS.vacRfc} label="">{text}</TextLink>,
forum: (text: string) => <TextLink href={EXTERNAL_URLS.researchForum} label="">{text}</TextLink>,
principles: (text: string) => <TextLink href={EXTERNAL_URLS.researchPrinciples} label="">{text}</TextLink>,
}
const OVERVIEW_PARAGRAPH_KEYS = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] as const
export default async function ResearchPageView({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'pages.research' })
const heroCtas = t.raw('hero.ctas') as LinkItem[]
const overviewLinkTags = {
logos: overviewLink(EXTERNAL_URLS.logosHome),
ift: overviewLink(EXTERNAL_URLS.ift),
units: overviewLink(EXTERNAL_URLS.researchServiceUnits),
incubator: overviewLink(EXTERNAL_URLS.researchIncubatorProjects),
research: overviewLink(EXTERNAL_URLS.researchApplied),
specs: overviewLink(EXTERNAL_URLS.vacRfc),
forum: overviewLink(EXTERNAL_URLS.researchForum),
principles: overviewLink(EXTERNAL_URLS.researchPrinciples),
}
export default function ResearchPageView({
data,
}: {
data: ResearchCopySection
locale: string
}) {
const contribute = data.contribute.copy as ContributeCopy
const overviewParagraphs = OVERVIEW_PARAGRAPH_KEYS.map((key) =>
t.rich(`overview.body.paragraphs.${key}`, overviewLinkTags)
renderTaggedText(data.overview.body.paragraphs[key] ?? '', overviewLinkRenderers)
)
const resources = t.raw('resources.items') as ResourceItem[]
const contribute = t.raw('contribute.copy') as ContributeCopy
const contributeContact = t.rich('contribute.copy.contact', {
const contributeContact = renderTaggedText(contribute.contact, {
discord: () => (
<TextLink href={EXTERNAL_URLS.researchDiscord} label="">
{contribute.links.discord}
@@ -56,7 +52,8 @@ export default async function ResearchPageView({ locale }: { locale: string }) {
</TextLink>
),
})
const contributeJobs = t.rich('contribute.copy.jobs', {
const contributeJobs = renderTaggedText(contribute.jobs, {
jobs: () => (
<TextLink href={EXTERNAL_URLS.iftJobs} label="">
{contribute.links.jobs}
@@ -67,26 +64,23 @@ export default async function ResearchPageView({ locale }: { locale: string }) {
return (
<div className="flex flex-col gap-10 bg-brand-off-white pt-10 pb-10">
<HeroSection
kicker={t('hero.kicker')}
title={t('hero.title')}
description={t('hero.description')}
ctas={heroCtas}
kicker={data.hero.kicker}
title={data.hero.title}
description={data.hero.description}
ctas={data.hero.ctas}
/>
<OverviewSection
title={t('overview.title')}
title={data.overview.title}
paragraphs={overviewParagraphs}
cta={{
label: t('overview.cta.label'),
href: t('overview.cta.href'),
}}
cta={data.overview.cta}
/>
<ResourcesSection
title={t('resources.title')}
learnMoreLabel={t('resources.learnMore')}
items={resources}
title={data.resources.title}
learnMoreLabel={data.resources.learnMore}
items={data.resources.items}
/>
<ContributeSection
title={t('contribute.title')}
title={data.contribute.title}
copy={contribute}
contact={contributeContact}
jobs={contributeJobs}
@@ -0,0 +1,86 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const pageState = vi.hoisted(() => ({
page: {
sections: [] as Array<Record<string, unknown>>,
},
}))
const createOverviewSection = (
overrides: Readonly<Record<string, unknown>> = {}
) => ({
componentType: 'techStackOverview',
key: 'techStack.overview',
title: 'The Logos Technology Stack.',
pillars: [],
networkingTitle: 'Networking',
foundationTitle: 'Runtime',
...overrides,
})
vi.mock('@repo/content/loaders', () => ({
getPageCopy: vi.fn(async () => pageState.page),
}))
vi.mock('@/components/layout/content-width', () => ({
default: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('@/components/motion/reveal', () => ({
Reveal: ({ children, className }: { children: ReactNode; className?: string }) =>
createElement('div', { className }, children),
RevealItem: ({
children,
className,
}: {
children: ReactNode
className?: string
}) => createElement('div', { className }, children),
}))
vi.mock('../tech-stack-diagram', () => ({
TechStackDiagram: () => createElement('div', { 'data-testid': 'diagram' }),
}))
import TechStackExplorer from '../tech-stack-explorer'
describe('TechStackExplorer', () => {
beforeEach(() => {
pageState.page = {
sections: [createOverviewSection()],
}
})
it('fails loudly when explorer copy is missing', async () => {
await expect(
TechStackExplorer({ locale: 'en' })
).rejects.toThrow(
'technology-stack techStackOverview.explorer copy is required'
)
})
it('renders explorer copy from content', async () => {
pageState.page.sections[0] = createOverviewSection({
explorer: {
titleLine1: 'Explore the Logos',
titleLine2: 'Technology Stack.',
body: 'A modular ecosystem of distinct modules.',
},
})
const element = await TechStackExplorer({ locale: 'en' })
const html = renderToStaticMarkup(element)
expect(html).toContain('Explore the Logos')
expect(html).toContain('Technology Stack.')
expect(html).toContain('A modular ecosystem of distinct modules.')
})
})
@@ -1,4 +1,3 @@
import { getTranslations } from 'next-intl/server'
import { twMerge } from 'tailwind-merge'
import { getPageCopy } from '@repo/content/loaders'
@@ -20,10 +19,6 @@ export default async function TechStackExplorer({
locale: Language
contentClassName?: string
}) {
const t = await getTranslations({
locale,
namespace: 'pages.technologyStack.stack',
})
const page = await getPageCopy(ROUTES.technologyStack, locale)
const overview = findTechnologyStackSection<TechStackOverviewSection>(
page.sections,
@@ -31,6 +26,14 @@ export default async function TechStackExplorer({
'techStack.overview'
)
if (!overview.explorer) {
throw new Error(
'technology-stack techStackOverview.explorer copy is required'
)
}
const { titleLine1, titleLine2, body } = overview.explorer
return (
<section className="border-brand-dark-green/10 bg-brand-off-white border-t">
<ContentWidth
@@ -42,13 +45,13 @@ export default async function TechStackExplorer({
>
<RevealItem className="min-[1367px]:w-178.5">
<h2 className="text-h4-sans">
{t('titleLine1')}
{titleLine1}
<br />
{t('titleLine2')}
{titleLine2}
</h2>
</RevealItem>
<RevealItem className="min-[1367px]:w-83.5">
<p className="text-mono-s">{t('body')}</p>
<p className="text-mono-s">{body}</p>
</RevealItem>
</Reveal>
-1
View File
@@ -63,7 +63,6 @@ export const ROUTES = {
research: '/research',
// Info / Help
faq: '/faq',
testnetFaqs: '/testnet-faqs',
testnetTermsAndConditions: '/testnet-terms-and-conditions',
@@ -9,8 +9,6 @@ import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import messages from '../../messages/en.json' with { type: 'json' }
const locale = 'en'
type SectionContract = {
@@ -30,13 +28,15 @@ const contracts: PageContract[] = [
name: 'home',
sections: [
{ componentType: 'hero', key: 'home.atf' },
{ componentType: 'homeSocialProof', key: 'home.socialProof' },
{ componentType: 'homeChoosePath', key: 'home.paths' },
{ componentType: 'homeAbout', key: 'home.about' },
{ componentType: 'homeDecide', key: 'home.decide' },
{ componentType: 'homeUseCases', key: 'home.useCases' },
{ componentType: 'homeBuilderPortal', key: 'home.builderPortal' },
{ componentType: 'techStackOverview', key: 'home.techStack' },
{ componentType: 'cardGrid', key: 'home.useCases' },
{ componentType: 'featuredText', key: 'home.parallelSocietyHeadline' },
{ componentType: 'gallery', key: 'home.parallelSociety' },
{ componentType: 'featuredText', key: 'home.mountain' },
{ componentType: 'homeStartBuilding', key: 'home.startBuilding' },
{ componentType: 'relatedArticles', key: 'home.blog' },
{ componentType: 'featuredText', key: 'home.circlesCta' },
],
},
{
@@ -94,6 +94,89 @@ const contracts: PageContract[] = [
{ componentType: 'relatedArticles', key: 'storage.relatedArticles' },
],
},
{
route: ROUTES.getStarted,
name: 'get-started',
sections: [{ componentType: 'getStartedCopy', key: 'getStarted.copy' }],
},
{
route: ROUTES.movement,
name: 'movement',
sections: [{ componentType: 'movementCopy', key: 'movement.copy' }],
},
{
route: ROUTES.research,
name: 'research',
sections: [{ componentType: 'researchCopy', key: 'research.copy' }],
},
{
route: ROUTES.book,
name: 'book',
sections: [{ componentType: 'bookCopy', key: 'book.copy' }],
},
{
route: ROUTES.nodeProgramme,
name: 'node-programme',
sections: [
{ componentType: 'nodeProgrammeCopy', key: 'nodeProgramme.copy' },
],
},
{
route: ROUTES.lambdaPrize,
name: 'lambda-prize',
sections: [{ componentType: 'lambdaPrizeCopy', key: 'lambdaPrize.copy' }],
},
{
route: ROUTES.manifesto,
name: 'manifesto',
sections: [{ componentType: 'manifestoCopy', key: 'manifesto.copy' }],
},
{
route: ROUTES.media,
name: 'media',
sections: [{ componentType: 'mediaCopy', key: 'media.copy' }],
},
{
route: ROUTES.podcast,
name: 'podcast',
sections: [{ componentType: 'podcastCopy', key: 'podcast.copy' }],
},
{
route: ROUTES.logosBroadcastNetwork,
name: 'logos-broadcast-network',
sections: [
{ componentType: 'broadcastCopy', key: 'logosBroadcastNetwork.copy' },
],
},
{
route: ROUTES.activistBuilder,
name: 'activist-builder',
sections: [
{ componentType: 'activistBuilderCopy', key: 'activistBuilder.copy' },
],
},
{
route: ROUTES.activistLeaderSteward,
name: 'activist-leader-steward',
sections: [
{
componentType: 'activistLeaderStewardCopy',
key: 'activistLeaderSteward.copy',
},
],
},
{
route: ROUTES.coalitionPartner,
name: 'coalition-partner',
sections: [
{ componentType: 'coalitionPartnerCopy', key: 'coalitionPartner.copy' },
],
},
{
route: ROUTES.designGuide,
name: 'design-guide',
sections: [{ componentType: 'designGuideCopy', key: 'designGuide.copy' }],
},
]
describe('content-backed web route contracts', () => {
@@ -145,8 +228,15 @@ describe('content-backed web route contracts', () => {
])
})
test('movement page builder highlight uses the Benin fundraising issue', () => {
const builder = messages.pages.movement.builder
test('movement page builder highlight uses the Benin fundraising issue', async () => {
const page = await getPageCopy(ROUTES.movement, locale)
const findSection = createSectionFinder('movement')
const section = findSection<{
componentType: string
key: string
builder: { feature: unknown; details: unknown }
}>(page.sections, 'movementCopy', 'movement.copy')
const { builder } = section
expect(builder.feature).toEqual({
city: 'Benin',
@@ -172,16 +262,32 @@ describe('content-backed web route contracts', () => {
)
})
test('design guide page uses the current asset download copy', () => {
expect(messages.pages.designGuide).toMatchObject({
test('design guide page uses the current asset download copy', async () => {
const page = await getPageCopy(ROUTES.designGuide, locale)
const findSection = createSectionFinder('design-guide')
const section = findSection(
page.sections,
'designGuideCopy',
'designGuide.copy'
)
expect(page).toMatchObject({
description:
'Design assets, guidelines, and resources for using the Logos visual identity. Logos is open by design, open in spirit. Here are some assets to guide you as you chart your own path.',
})
expect(section).toMatchObject({
intro:
'Design assets, guidelines, and resources for using the Logos visual identity. Logos is open by design, open in spirit. Here are some assets to guide you as you chart your own path.',
downloads: {
brandMarksLabel: 'Download Marks',
brandMarks: {
label: 'Download Marks',
href: '/brand-kit/brand-marks.zip',
},
guidelinesSection: 'Design Guide',
guidelinesLabel: 'Download Design Guide',
guidelines: {
label: 'Download Design Guide',
href: '/brand-kit/logos-brand-guidelines.pdf',
},
},
})
})
@@ -0,0 +1,21 @@
import { getPageCopy } from '@repo/content/loaders'
import type { HomeAboutSection } from '@repo/content/schemas'
import { describe, expect, it } from 'vitest'
import { ROUTES } from '@/constants/routes'
import { createSectionFinder } from '@/lib/page-sections'
describe('homepage about content', () => {
it('keeps every about problem populated', async () => {
const page = await getPageCopy(ROUTES.home, 'en')
const about = createSectionFinder('home')<HomeAboutSection>(
page.sections,
'homeAbout',
'home.about'
)
for (const problem of Object.values(about.problems)) {
expect(problem.title.trim().length).toBeGreaterThan(0)
expect(problem.facts.length).toBeGreaterThan(0)
}
})
})
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import messages from '../../messages/en.json' with { type: 'json' }
describe('homepage about copy', () => {
it('keeps the homepage about copy keys present and non-empty', () => {
// Copy wording is allowed to change freely; we only guard the structure
// so the about section never renders with missing/blank text.
for (const key of [
'intro',
'closing1',
'closing2',
'closing3',
] as const) {
expect(typeof messages.home.about[key]).toBe('string')
expect(messages.home.about[key].trim().length).toBeGreaterThan(0)
}
})
})
@@ -12,6 +12,12 @@ const repoRoot = path.resolve(import.meta.dirname, '../../../..')
describe('homepage review contracts', () => {
test('maps social proof cards to the stats that match their labels', () => {
const copy = {
contributions: { label: 'Contributors', body: 'Contributors body' },
nodeOperators: { label: 'Node Operators', body: 'Node Operators body' },
circles: { label: 'Circles', body: 'Circles body' },
winnableIssues: { label: 'Winnable Issues', body: 'Winnable Issues body' },
}
const cards = getSocialProofCards(
{
contributions: '3,441',
@@ -19,28 +25,28 @@ describe('homepage review contracts', () => {
repositories: '341',
},
'19',
(key) => key
copy
)
expect(cards).toContainEqual(
expect.objectContaining({
key: 'contributions',
value: '218',
label: 'contributions.label',
label: 'Contributors',
})
)
expect(cards).toContainEqual(
expect.objectContaining({
key: 'circles',
value: '47',
label: 'circles.label',
label: 'Circles',
})
)
expect(cards).toContainEqual(
expect.objectContaining({
key: 'winnableIssues',
value: '19',
label: 'winnableIssues.label',
label: 'Winnable Issues',
})
)
})
+28 -26
View File
@@ -12,6 +12,7 @@ import {
import buildersHubResources from '../../../../content/builders-hub/resources/en.json' with { type: 'json' }
import buildersHubSettings from '../../../../content/builders-hub/settings/en.json' with { type: 'json' }
import homePage from '../../../../content/pages/en/home.json' with { type: 'json' }
import manifestoContentPage from '../../../../content/pages/en/manifesto.json' with { type: 'json' }
import messages from '../../messages/en.json' with { type: 'json' }
import footer from '../../../../content/site/en/footer.json' with { type: 'json' }
import navigation from '../../../../content/site/en/navigation.json' with { type: 'json' }
@@ -47,7 +48,6 @@ const jobsHref = 'https://free.technology/jobs'
const onboardingCalendarHref = 'https://cal.com/team/logos-onboarding/intro'
const logosDocsHref = 'https://docs.logos.co/'
const communityIdeasHref = 'https://github.com/logos-co/ideas'
const parallelSocietyHref = 'https://ps.logos.co/'
const livingWithinTruthHref = 'https://www.youtube.com/watch?v=xy4uK20lFBQ'
const logosGenealogyHref = 'https://blog.logos.co/article/a-genealogy-of-logos'
const basecampReleaseHref =
@@ -184,17 +184,19 @@ describe('link policy', () => {
'utf8'
)
const useCasesContentSection = homePage.sections.find(
(section) => section.key === 'home.useCases'
) as { lambda: string } | undefined
expect(useCasesContentSection).toBeDefined()
const lambdaCopy = useCasesContentSection?.lambda ?? ''
expect(ROUTES.lambdaPrize).toBe('/lambda-prize')
expect(
messages.home.useCases.lambda.replace(/<\/?lambdaPrize>/g, '')
).toBe(
expect(lambdaCopy.replace(/<\/?lambdaPrize>/g, '')).toBe(
'Explore the applications Logos is funding through the Lambda Prize.'
)
expect(messages.home.useCases.lambda).toContain(
'<lambdaPrize>Lambda Prize</lambdaPrize>'
)
expect(messages.home.useCases.lambda).not.toContain('LAMBDA PRIZE >>')
expect(useCasesSection).toContain('href={ROUTES.lambdaPrize}')
expect(lambdaCopy).toContain('<lambdaPrize>Lambda Prize</lambdaPrize>')
expect(lambdaCopy).not.toContain('LAMBDA PRIZE >>')
expect(useCasesSection).toMatch(/renderLambdaPrizeText\([^)]*ROUTES\.lambdaPrize/)
})
it('routes manifesto related reading links to their target pages', () => {
@@ -206,7 +208,8 @@ describe('link policy', () => {
expect(ROUTES.book).toBe('/book')
expect(EXTERNAL_URLS.livingWithinTruth).toBe(livingWithinTruthHref)
expect(EXTERNAL_URLS.logosGenealogyArticle).toBe(logosGenealogyHref)
expect(messages.pages.manifesto.more).toEqual([
const manifestoSection = manifestoContentPage.sections[0] as { more?: string[] }
expect(manifestoSection.more).toEqual([
'Farewell to Westphalia',
'Living Within the Truth | Parallel Society',
'From Offline to Online Piracy: A Genealogy of Logos',
@@ -238,6 +241,21 @@ describe('link policy', () => {
expect(offenders).toEqual([])
})
it('does not link to the removed general FAQ route', () => {
const offenders = scannedRoots.flatMap(collectTextFiles).flatMap((file) => {
const text = readFileSync(file, 'utf8')
const relativePath = relative(repoRoot, file)
const matches = [
...text.matchAll(/\bROUTES\.faq\b/g),
...text.matchAll(/["']\/faq["']/g),
]
return matches.map((match) => `${relativePath}:${match.index}`)
})
expect(offenders).toEqual([])
})
it('routes Field Guide chapter links through served routes', () => {
const chapterSlugs = new Set(
fieldGuideManifest.sections.flatMap((section) =>
@@ -313,22 +331,6 @@ describe('link policy', () => {
)
})
it('routes the homepage Parallel Society CTA to the event site', () => {
const parallelSocietyHeadline = homePage.sections.find(
(section) => section.key === 'home.parallelSocietyHeadline'
)
// The CTA label is copy; assert the route and external flag only.
expect(parallelSocietyHeadline).toEqual(
expect.objectContaining({
cta: expect.objectContaining({
href: parallelSocietyHref,
external: true,
}),
})
)
})
it('routes Docs and Documentation links to the Logos docs site', () => {
const contentDocsLinks = [
footer,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import messages from '../../messages/en.json' with { type: 'json' }
describe('messages ownership contract', () => {
it('contains no home page-copy namespace (content owns home copy)', () => {
expect(Object.keys(messages)).not.toContain('home')
})
it('keeps shared chrome namespaces', () => {
for (const key of ['common', 'pages', 'connectForm'] as const) {
expect(messages).toHaveProperty(key)
}
})
it('contains no get-started or movement page copy in messages.pages', () => {
const pages = (messages as { pages: Record<string, unknown> }).pages
expect(Object.keys(pages)).not.toContain('getStarted')
expect(Object.keys(pages)).not.toContain('movement')
})
it('contains no migrated navbar page copy in messages.pages (content owns these)', () => {
const pages = (messages as { pages: Record<string, unknown> }).pages
const migratedNamespaces = [
'book',
'brandKit',
'research',
'nodeProgramme',
'lambdaPrize',
'manifesto',
'blog',
'podcast',
'logosBroadcastNetwork',
'technologyStack',
]
for (const ns of migratedNamespaces) {
expect(Object.keys(pages)).not.toContain(ns)
}
})
it('contains no activist/coalition page copy in messages.pages (content owns these)', () => {
const pages = (messages as { pages: Record<string, unknown> }).pages
const migratedNamespaces = [
'activistBuilder',
'activistLeaderSteward',
'coalitionPartner',
]
for (const ns of migratedNamespaces) {
expect(Object.keys(pages)).not.toContain(ns)
}
})
})
@@ -0,0 +1,179 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { renderTaggedText } from '../render-tagged-text'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function anchor(href: string, text: string) {
return React.createElement('a', { href }, text)
}
// ---------------------------------------------------------------------------
// Basic rendering
// ---------------------------------------------------------------------------
describe('renderTaggedText', () => {
it('returns plain text unchanged when no tags are present', () => {
const result = renderTaggedText('Hello world', {})
expect(result).toEqual(['Hello world'])
})
it('replaces a single paired tag with the renderer output', () => {
const result = renderTaggedText('Click <link>here</link> to continue.', {
link: (text) => anchor('https://example.com', text),
})
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toBe('Click <a href="https://example.com">here</a> to continue.')
})
it('replaces multiple different tags in order', () => {
const result = renderTaggedText('A <foo>alpha</foo> and <bar>beta</bar>.', {
foo: (text) => anchor('/foo', text),
bar: (text) => anchor('/bar', text),
})
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toBe('A <a href="/foo">alpha</a> and <a href="/bar">beta</a>.')
})
it('handles self-closing empty tags where the renderer supplies the label', () => {
const result = renderTaggedText('Join <discord></discord> today.', {
discord: () => anchor('https://discord.gg/test', 'our Discord'),
})
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toBe('Join <a href="https://discord.gg/test">our Discord</a> today.')
})
it('assigns a unique key to each rendered element', () => {
const result = renderTaggedText('<a>one</a> and <a>two</a>', {
a: (text) => React.createElement('span', null, text),
})
const elements = result.filter(React.isValidElement) as React.ReactElement[]
const keys = elements.map((el) => el.key)
expect(keys).toHaveLength(2)
expect(new Set(keys).size).toBe(2)
})
it('leaves unrecognised tags in the output as literal text (HTML-escaped)', () => {
const result = renderTaggedText('Hello <unknown>world</unknown> end', {
other: (text) => anchor('/other', text),
})
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
// React escapes angle brackets in text nodes; the raw string is preserved as a text node
expect(html).toBe('Hello &lt;unknown&gt;world&lt;/unknown&gt; end')
})
})
// ---------------------------------------------------------------------------
// Research content: overview paragraph p1
// The template is taken verbatim from content/pages/en/research.json
// ---------------------------------------------------------------------------
describe('renderTaggedText with research overview p1', () => {
const p1 =
'Logos Research, formerly Vac, is a principle-driven R&D team that provides comprehensive technical support to <logos>Logos</logos> and other <ift>IFT</ift> projects. Logos Research consists of specialised <units>R&D service units</units> and innovative <incubator>incubator projects</incubator>. We focus on developing foundational components and unopinionated protocols that both IFT projects and the broader community can utilise.'
const renderers = {
logos: (text: string) => anchor('https://logos.co/', text),
ift: (text: string) => anchor('https://free.technology', text),
units: (text: string) => anchor('https://research.logos.co/vsus', text),
incubator: (text: string) => anchor('https://research.logos.co/vips', text),
}
it('renders visible text with tags stripped', () => {
const result = renderTaggedText(p1, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('Logos Research, formerly Vac')
expect(html).toContain('Logos')
expect(html).toContain('IFT')
expect(html).toContain('R&amp;D service units')
expect(html).toContain('incubator projects')
})
it('renders the logos link with correct href and label', () => {
const result = renderTaggedText(p1, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://logos.co/"')
expect(html).toContain('>Logos<')
})
it('renders the IFT link with correct href', () => {
const result = renderTaggedText(p1, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://free.technology"')
expect(html).toContain('>IFT<')
})
it('renders the R&D service units link', () => {
const result = renderTaggedText(p1, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://research.logos.co/vsus"')
})
it('renders the incubator projects link', () => {
const result = renderTaggedText(p1, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://research.logos.co/vips"')
})
})
// ---------------------------------------------------------------------------
// Research content: contribute contact line
// The template is taken verbatim from content/pages/en/research.json
// ---------------------------------------------------------------------------
describe('renderTaggedText with research contribute contact', () => {
const contact = 'Get in touch with us by <discord></discord>, <forum></forum>, or <github></github>.'
const links = {
discord: 'joining our Discord',
forum: 'opening a thread in our forum',
github: 'opening issues / PRs on GitHub',
}
const renderers = {
discord: () => anchor('https://discord.gg/PQFdubGt6d', links.discord),
forum: () => anchor('https://forum.research.logos.co/', links.forum),
github: () => anchor('https://github.com/vacp2p', links.github),
}
it('renders visible surroundings text', () => {
const result = renderTaggedText(contact, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('Get in touch with us by')
expect(html).toContain(', or ')
})
it('renders the discord link with correct href and label', () => {
const result = renderTaggedText(contact, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://discord.gg/PQFdubGt6d"')
expect(html).toContain('joining our Discord')
})
it('renders the forum link with correct href and label', () => {
const result = renderTaggedText(contact, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://forum.research.logos.co/"')
expect(html).toContain('opening a thread in our forum')
})
it('renders the github link with correct href and label', () => {
const result = renderTaggedText(contact, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
expect(html).toContain('href="https://github.com/vacp2p"')
expect(html).toContain('opening issues / PRs on GitHub')
})
it('renders all three links in document order', () => {
const result = renderTaggedText(contact, renderers)
const html = renderToStaticMarkup(React.createElement(React.Fragment, null, ...result))
const discordIdx = html.indexOf('discord.gg')
const forumIdx = html.indexOf('forum.research')
const githubIdx = html.indexOf('github.com')
expect(discordIdx).toBeLessThan(forumIdx)
expect(forumIdx).toBeLessThan(githubIdx)
})
})
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { rewriteRfpMarkdownLinks } from '@/lib/rfps-github'
import {
fetchRfpMarkdownEntryForTest,
rewriteRfpMarkdownLinks,
} from '@/lib/rfps-github'
const FILE_URL =
'https://github.com/logos-co/rfp/blob/master/RFPs/RFP-016-lbp-launchpad.md'
@@ -47,3 +50,56 @@ describe('rewriteRfpMarkdownLinks', () => {
expect(rewriteRfpMarkdownLinks(md, FILE_URL)).toBe(md)
})
})
describe('fetchRfpMarkdownEntryForTest', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('falls back from download_url to raw GitHub URL when the first fetch fails', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('# RFP raw markdown', { status: 200 }))
const result = await fetchRfpMarkdownEntryForTest({
name: 'RFP-001-example.md',
download_url: 'https://objects.githubusercontent.com/RFP-001-example.md',
html_url:
'https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-example.md',
git_url: 'https://api.github.com/repos/logos-co/rfp/git/blobs/example',
})
expect(result).toBe('# RFP raw markdown')
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(fetchMock.mock.calls[3]?.[0]).toBe(
'https://raw.githubusercontent.com/logos-co/rfp/master/RFPs/RFP-001-example.md'
)
})
it('decodes GitHub blob content when raw URL fetches fail', async () => {
const encoded = Buffer.from('# RFP blob markdown', 'utf8').toString('base64')
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
.mockResolvedValueOnce(
Response.json({ content: encoded, encoding: 'base64' })
)
const result = await fetchRfpMarkdownEntryForTest({
name: 'RFP-002-example.md',
download_url: 'https://objects.githubusercontent.com/RFP-002-example.md',
html_url:
'https://github.com/logos-co/rfp/blob/master/RFPs/RFP-002-example.md',
git_url: 'https://api.github.com/repos/logos-co/rfp/git/blobs/example',
})
expect(result).toBe('# RFP blob markdown')
})
})
@@ -344,11 +344,6 @@ export const AFFORM_FIELDS = [
}
];
export const AFFORM_PAGE_HEADING = "Activist Builder";
export const AFFORM_PAGE_INTRO = "If you enjoy building tools, systems, research, media, or infrastructure that help communities solve real problems, we'd love to hear from you.\n\nTell us a bit about your background, skills, and the kinds of projects you'd like to contribute to. Not every field is required, but additional context helps us understand where you could have the most impact.\n\nIf your primary interest is building apps directly on the Logos stack, the [Builders Hub](http://build.logos.co) is likely a better fit.\n\nWe'll review your submission and be in touch.";
export const AFFORM_PAGE_PRIVACY = "We respect your privacy. By clicking 'Submit', you consent to our processing of your personal data as described above and in our Privacy Policy .";
export const AFFORM_PAGE_PRIVACY_LINK = "https://logos.co/privacy-policy";
export const AFFORM_OPTIONS = {
"role": [
{
@@ -344,11 +344,6 @@ export const AFFORM_FIELDS = [
}
];
export const AFFORM_PAGE_HEADING = "Activist Leader / Steward";
export const AFFORM_PAGE_INTRO = "If you care about building stronger local communities, bringing people together, and turning ideas into action, you might be steward material. We're looking for people who want to build something durable in their area - not just host occasional meetups, but create a real local community with structure, continuity, and impact.\n\nLet us know how we can reach you, tell us a bit more about yourself, and share why you'd like to start a Circle in your area. Not every field is required, but more context helps us understand the fit and how we can best support you. We'll review your submission and be in touch.\n\nIf you want to learn more about Circles first, please check out our [Circle Guide](http://circles.logos.co)";
export const AFFORM_PAGE_PRIVACY = "We respect your privacy. By clicking 'Submit', you consent to our processing of your personal data as described above and in our Privacy Policy .";
export const AFFORM_PAGE_PRIVACY_LINK = "https://logos.co/privacy-policy";
export const AFFORM_OPTIONS = {
"role": [
{
@@ -300,11 +300,6 @@ export const AFFORM_FIELDS = [
}
];
export const AFFORM_PAGE_HEADING = "Let's Connect.";
export const AFFORM_PAGE_INTRO = "Be part of whats unfolding. Receive updates on progress, gatherings and opportunities to contribute.";
export const AFFORM_PAGE_PRIVACY = "We respect your privacy. By clicking 'Submit', you consent to our processing of your personal data as described above and in our Privacy Policy .";
export const AFFORM_PAGE_PRIVACY_LINK = "https://logos.co/privacy-policy";
export const AFFORM_OPTIONS = {
"role": [
{
@@ -322,11 +322,6 @@ export const AFFORM_FIELDS = [
}
];
export const AFFORM_PAGE_HEADING = "Coalition Partner";
export const AFFORM_PAGE_INTRO = "If you're already building something in your community, ecosystem, or area of focus, we'd love to hear from you. We're looking for aligned organisations, networks, and projects creating practical alternatives around civic life, education, governance, local resilience, technology, media, mutual aid, or community infrastructure.\n\nLet us know how we can reach you, what you're building, and where you see potential to collaborate. Not every field is required, but more context helps us understand the fit. We'll review your submission and be in touch.";
export const AFFORM_PAGE_PRIVACY = "We respect your privacy. By clicking 'Submit', you consent to our processing of your personal data as described above and in our Privacy Policy .";
export const AFFORM_PAGE_PRIVACY_LINK = "https://logos.co/privacy-policy";
export const AFFORM_OPTIONS = {
"role": [
{
+15 -10
View File
@@ -12,7 +12,12 @@ export interface HomeStatCard {
body: string
}
type Translate = (key: string) => string
interface SocialProofCopy {
contributions: { label: string; body: string }
nodeOperators: { label: string; body: string }
circles: { label: string; body: string }
winnableIssues: { label: string; body: string }
}
export const HOME_USE_CASE_CARDS: HomeUseCaseCard[] = [
{ key: 'secure', image: '/images/home/use-cases/secure.png' },
@@ -24,32 +29,32 @@ export const HOME_USE_CASE_CARDS: HomeUseCaseCard[] = [
export function getSocialProofCards(
stats: SocialProofStats,
winnableIssuesCount: string,
t: Translate
copy: SocialProofCopy
): HomeStatCard[] {
return [
{
key: 'contributions',
value: stats.contributors,
label: t('contributions.label'),
body: t('contributions.body'),
label: copy.contributions.label,
body: copy.contributions.body,
},
{
key: 'nodeOperators',
value: '226',
label: t('nodeOperators.label'),
body: t('nodeOperators.body'),
label: copy.nodeOperators.label,
body: copy.nodeOperators.body,
},
{
key: 'circles',
value: '47',
label: t('circles.label'),
body: t('circles.body'),
label: copy.circles.label,
body: copy.circles.body,
},
{
key: 'winnableIssues',
value: winnableIssuesCount,
label: t('winnableIssues.label'),
body: t('winnableIssues.body'),
label: copy.winnableIssues.label,
body: copy.winnableIssues.body,
},
]
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react'
type TagRenderer = (innerText: string) => React.ReactNode
/**
* Renders a template string with non-nested XML-like tags.
* Tags can be either:
* - Paired: <tag>inner text</tag>
* - Self-closing empty: <tag></tag> (renderer supplies its own label)
*
* @param template - The template string with <tag>...</tag> markup
* @param renderers - Map of tag name -> function that receives inner text and returns ReactNode
* @returns Array of ReactNodes (text segments and rendered tag nodes)
*/
export function renderTaggedText(
template: string,
renderers: Record<string, TagRenderer>
): React.ReactNode[] {
const tagNames = Object.keys(renderers).join('|')
const regex = new RegExp(`<(${tagNames})>(.*?)<\\/\\1>`, 'gs')
const result: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
let keyIdx = 0
while ((match = regex.exec(template)) !== null) {
// Add text before this match
if (match.index > lastIndex) {
result.push(template.slice(lastIndex, match.index))
}
const [, tagName, innerText] = match
const renderer = renderers[tagName]
if (renderer) {
const node = renderer(innerText)
result.push(
React.isValidElement(node)
? React.cloneElement(node as React.ReactElement, { key: `tag-${keyIdx++}` })
: node
)
}
lastIndex = regex.lastIndex
}
// Add remaining text after last match
if (lastIndex < template.length) {
result.push(template.slice(lastIndex))
}
return result
}
+82 -6
View File
@@ -55,6 +55,7 @@ type GithubContentEntry = {
name: string
download_url: string | null
html_url: string | null
git_url?: string | null
}
const isContentEntry = (value: unknown): value is GithubContentEntry =>
@@ -62,6 +63,84 @@ const isContentEntry = (value: unknown): value is GithubContentEntry =>
value !== null &&
typeof (value as GithubContentEntry).name === 'string'
type GithubBlobResponse = {
content?: string
encoding?: string
}
const fetchTextWithRetry = async (
url: string,
headers: Readonly<Record<string, string>>,
attempts = 3
): Promise<string | null> => {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const res = await fetch(url, { headers })
if (res.ok) return await res.text()
if (attempt === attempts) return null
} catch {
if (attempt === attempts) return null
}
}
return null
}
const fetchGithubBlob = async (
url: string,
headers: Readonly<Record<string, string>>
): Promise<string | null> => {
try {
const res = await fetch(url, { headers })
if (!res.ok) return null
const body = (await res.json()) as GithubBlobResponse
if (body.encoding !== 'base64' || typeof body.content !== 'string') {
return null
}
return Buffer.from(body.content.replace(/\s/g, ''), 'base64').toString(
'utf8'
)
} catch {
return null
}
}
const rawUrlFromHtmlUrl = (htmlUrl: string | null): string | null => {
if (!htmlUrl) return null
try {
const url = new URL(htmlUrl)
if (url.hostname !== 'github.com') return null
url.hostname = 'raw.githubusercontent.com'
url.pathname = url.pathname.replace('/blob/', '/')
return url.href
} catch {
return null
}
}
const fetchRfpMarkdownEntry = async (
entry: GithubContentEntry
): Promise<string | null> => {
const headers = githubHeaders()
if (entry.download_url) {
const raw = await fetchTextWithRetry(entry.download_url, headers)
if (raw) return raw
}
const rawUrl = rawUrlFromHtmlUrl(entry.html_url)
if (rawUrl && rawUrl !== entry.download_url) {
const raw = await fetchTextWithRetry(rawUrl, headers)
if (raw) return raw
}
if (entry.git_url) {
return fetchGithubBlob(entry.git_url, headers)
}
return null
}
export const fetchRfpMarkdownEntryForTest = fetchRfpMarkdownEntry
/** `RFP-001-admin-authority-lib.md` → `admin-authority-lib`. */
const toSlug = (filename: string, fallback: string): string => {
const base = filename
@@ -262,14 +341,11 @@ export const fetchGithubRfps = cache(async (): Promise<GithubRfp[]> => {
const parsed = await Promise.all(
mdFiles.map(async (entry): Promise<GithubRfp | null> => {
try {
const res = await fetch(entry.download_url as string, {
headers: githubHeaders(),
})
if (!res.ok) {
console.error(`Failed to fetch ${entry.name}: ${res.status}`)
const raw = await fetchRfpMarkdownEntry(entry)
if (!raw) {
console.error(`Failed to fetch ${entry.name}`)
return null
}
const raw = await res.text()
return parseRfpMarkdown(raw, entry.name, entry.html_url ?? RFP_REPO_URL)
} catch (error) {
console.error(`Failed to fetch ${entry.name}:`, error)
-49
View File
@@ -1,49 +0,0 @@
/**
* Builds the `generateMetadata` async export for pages whose copy is keyed
* into next-intl messages (rather than the PageCopy JSON system used by
* `createPageMetadata`). Removes the metadata half of the
* "namespace string repeated twice in one file" pattern and saves ~10 lines
* per page.
*
* Usage:
* import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata'
* const NAMESPACE = 'pages.designGuide'
* export const generateMetadata = createTranslatedPageMetadata({
* namespace: NAMESPACE,
* path: ROUTES.designGuide,
* })
*/
import { getTranslations } from 'next-intl/server'
import { createDefaultMetadata } from '@/lib/metadata'
type RouteParams = { params: Promise<{ locale: string }> }
type TranslatedPageMetadataConfig = {
/** next-intl namespace; must contain `title` and `description` keys. */
namespace: string
/** Canonical route path from `ROUTES`. */
path: string
/** Optional override for the title key (default `'title'`). */
titleKey?: string
/** Optional override for the description key (default `'description'`). */
descriptionKey?: string
}
export function createTranslatedPageMetadata({
namespace,
path,
titleKey = 'title',
descriptionKey = 'description',
}: TranslatedPageMetadataConfig) {
return async function generateMetadata({ params }: RouteParams) {
const { locale } = await params
const t = await getTranslations({ locale, namespace })
return createDefaultMetadata({
title: t(titleKey),
description: t(descriptionKey),
locale,
path,
})
}
}
File diff suppressed because it is too large Load Diff
@@ -832,11 +832,6 @@ export const AFFORM_NAME = ${JSON.stringify(form.name)};
export const AFFORM_FIELDS = ${JSON.stringify(allFields, null, 2)};
export const AFFORM_PAGE_HEADING = ${JSON.stringify(out.pageHeading)};
export const AFFORM_PAGE_INTRO = ${JSON.stringify(out.pageIntro)};
export const AFFORM_PAGE_PRIVACY = ${JSON.stringify(out.pagePrivacy)};
export const AFFORM_PAGE_PRIVACY_LINK = ${JSON.stringify(out.pagePrivacyLink)};
export const AFFORM_OPTIONS = ${JSON.stringify(afformOptions, null, 2)};
`
+7
View File
@@ -8,12 +8,19 @@ import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
export default defineConfig({
oxc: {
jsx: { runtime: 'automatic' },
},
test: {
environment: 'node',
include: [
'lib/**/*.test.ts',
'lib/**/*.test.tsx',
'lib/**/__tests__/**/*.test.ts',
'lib/**/__tests__/**/*.test.tsx',
'app/**/__tests__/**/*.test.ts',
'app/**/__tests__/**/*.test.tsx',
'components/**/__tests__/**/*.test.tsx',
],
coverage: {
provider: 'v8',
+1 -1
View File
@@ -364,7 +364,7 @@
"resourcesSection": {
"title": "Additional Resources",
"description": "Reference material to help you ship faster.",
"helpCenterCta": { "label": "Get help", "href": "/faq" }
"helpCenterCta": { "label": "Get help", "href": "https://docs.logos.co/", "external": true }
},
"documentation": {
"title": "Useful documentation links",

Some files were not shown because too many files have changed in this diff Show More