From 5d66b446b00f220f2dcd3dacae96d8bda801eb44 Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Thu, 1 Oct 2026 13:54:53 +0100 Subject: [PATCH 1/6] feat(public-site-content): content-driven How it works and Security pages (#373) Introduce the markdown+frontmatter content contract and loader so public copy lives in src/content/site instead of JSX, with build-time validation that aggregates every file's errors (including fence/YAML/BOM issues). Add a shared PublicLayout (header with mobile menu, footer, hash scroll) and route every public marketing page through it, including / and /legal, so footer links to /legal#privacy and #terms land on the right section. Add a frontend lint ratchet (lint:ci, BASELINE=87) enforced in CI and the pre-push hook, so the existing ESLint backlog can only shrink. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/frontend.yml | 4 + CLAUDE.md | 14 + docs/development/WORKFLOW.md | 7 +- frontend-typescript/eslint.config.js | 2 +- frontend-typescript/package-lock.json | 1347 ++++++++++++++++- frontend-typescript/package.json | 5 +- .../scripts/check-lint-baseline.mjs | 25 + frontend-typescript/src/App.tsx | 16 +- .../src/components/site/PublicLayout.test.tsx | 90 ++ .../src/components/site/PublicLayout.tsx | 177 +++ .../src/components/site/SectionVisibility.tsx | 12 + .../src/components/site/SiteMarkdown.test.tsx | 35 + .../src/components/site/SiteMarkdown.tsx | 23 + .../src/components/site/TbcMarker.tsx | 12 + .../src/content/site/README.md | 49 + .../content/site/how-it-works/five-steps.md | 18 + .../src/content/site/how-it-works/intro.md | 8 + .../src/content/site/how-it-works/roadmap.md | 8 + .../site/how-it-works/sector-validation.md | 17 + .../site/how-it-works/survey-evidence.md | 10 + .../content/site/how-it-works/who-funders.md | 15 + .../content/site/how-it-works/who-grantees.md | 15 + .../src/content/site/security/ai-providers.md | 8 + .../content/site/security/data-location.md | 8 + .../src/content/site/security/gdpr.md | 8 + .../src/content/site/security/high-risk.md | 10 + .../src/content/site/security/intro.md | 8 + .../src/content/site/security/self-hosting.md | 8 + .../src/lib/siteContent.test.ts | 152 ++ frontend-typescript/src/lib/siteContent.ts | 222 +++ .../src/pages/LandingPage.test.tsx | 9 +- frontend-typescript/src/pages/LandingPage.tsx | 106 +- frontend-typescript/src/pages/Legal.tsx | 309 ++-- .../src/pages/site/HowItWorks.test.tsx | 77 + .../src/pages/site/HowItWorks.tsx | 197 +++ .../src/pages/site/Security.test.tsx | 50 + .../src/pages/site/Security.tsx | 59 + .../src/pages/site/publicRoutes.test.tsx | 122 ++ .../src/pages/site/sectionLifecycle.test.tsx | 62 + .../design.md | 5 +- .../proposal.md | 5 +- .../specs/site-content/spec.md | 4 +- .../tasks.md | 26 +- scripts/git-hooks/pre-push | 15 +- 44 files changed, 3014 insertions(+), 365 deletions(-) create mode 100644 frontend-typescript/scripts/check-lint-baseline.mjs create mode 100644 frontend-typescript/src/components/site/PublicLayout.test.tsx create mode 100644 frontend-typescript/src/components/site/PublicLayout.tsx create mode 100644 frontend-typescript/src/components/site/SectionVisibility.tsx create mode 100644 frontend-typescript/src/components/site/SiteMarkdown.test.tsx create mode 100644 frontend-typescript/src/components/site/SiteMarkdown.tsx create mode 100644 frontend-typescript/src/components/site/TbcMarker.tsx create mode 100644 frontend-typescript/src/content/site/README.md create mode 100644 frontend-typescript/src/content/site/how-it-works/five-steps.md create mode 100644 frontend-typescript/src/content/site/how-it-works/intro.md create mode 100644 frontend-typescript/src/content/site/how-it-works/roadmap.md create mode 100644 frontend-typescript/src/content/site/how-it-works/sector-validation.md create mode 100644 frontend-typescript/src/content/site/how-it-works/survey-evidence.md create mode 100644 frontend-typescript/src/content/site/how-it-works/who-funders.md create mode 100644 frontend-typescript/src/content/site/how-it-works/who-grantees.md create mode 100644 frontend-typescript/src/content/site/security/ai-providers.md create mode 100644 frontend-typescript/src/content/site/security/data-location.md create mode 100644 frontend-typescript/src/content/site/security/gdpr.md create mode 100644 frontend-typescript/src/content/site/security/high-risk.md create mode 100644 frontend-typescript/src/content/site/security/intro.md create mode 100644 frontend-typescript/src/content/site/security/self-hosting.md create mode 100644 frontend-typescript/src/lib/siteContent.test.ts create mode 100644 frontend-typescript/src/lib/siteContent.ts create mode 100644 frontend-typescript/src/pages/site/HowItWorks.test.tsx create mode 100644 frontend-typescript/src/pages/site/HowItWorks.tsx create mode 100644 frontend-typescript/src/pages/site/Security.test.tsx create mode 100644 frontend-typescript/src/pages/site/Security.tsx create mode 100644 frontend-typescript/src/pages/site/publicRoutes.test.tsx create mode 100644 frontend-typescript/src/pages/site/sectionLifecycle.test.tsx diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 8c869cbe..37e3ef7f 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -40,6 +40,10 @@ jobs: working-directory: ./frontend-typescript run: npm ci + - name: Lint (baseline ratchet) + working-directory: ./frontend-typescript + run: npm run lint:ci + - name: Run Vitest working-directory: ./frontend-typescript run: npx vitest run --coverage diff --git a/CLAUDE.md b/CLAUDE.md index 6c224ae3..96e852f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,3 +67,17 @@ This is enforced, not just documented: `.claude/hooks/check_comment_brevity.py` (a `PreToolUse` hook on `Write`/`Edit`) denies any write that introduces a comment longer than that, so trim it before retrying rather than fighting the hook. + +## Frontend lint — fix on touch, never add to the pile + +`frontend-typescript` has a pre-existing lint backlog. Rather than block on +fixing it all at once, CI enforces a ratchet: `BASELINE` in +`frontend-typescript/scripts/check-lint-baseline.mjs` caps total ESLint +problems, checked by `npm run lint:ci`. A push that adds new lint errors +fails; one that fixes some should lower `BASELINE` to lock the win in. When +you touch a file that already has lint issues, fix them as part of that +change instead of leaving them for later. + +This is enforced, not just documented: `.github/workflows/frontend.yml` runs +`lint:ci` on every push/PR touching `frontend-typescript/**`, and +`scripts/git-hooks/pre-push` mirrors it locally (see WORKFLOW.md §8). diff --git a/docs/development/WORKFLOW.md b/docs/development/WORKFLOW.md index 38c4d5d0..152fa99d 100644 --- a/docs/development/WORKFLOW.md +++ b/docs/development/WORKFLOW.md @@ -161,8 +161,11 @@ rather than fast-forward-mergeable). Pass `--yes` to skip the prompt. (`black --check`, `mypy`, `flake8`) locally, scoped to whichever service(s) the push actually touches (a `shared/` change checks all four). A push touching `scripts/*.py` runs `black --check`, `flake8 --max-line-length=100` and -`scripts/test_flow.py` over `scripts/` — the workflow tooling lints itself. The -hook also rejects a branch whose name doesn't match §3. One-time setup: +`scripts/test_flow.py` over `scripts/` — the workflow tooling lints itself. A +push touching `frontend-typescript/*.{ts,tsx}` runs `npm run lint:ci`, the +same ESLint baseline ratchet as `frontend.yml` (see CLAUDE.md's "Frontend +lint" section). The hook also rejects a branch whose name doesn't match §3. +One-time setup: ``` git config core.hooksPath scripts/git-hooks diff --git a/frontend-typescript/eslint.config.js b/frontend-typescript/eslint.config.js index d94e7deb..3626c0e5 100644 --- a/frontend-typescript/eslint.config.js +++ b/frontend-typescript/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { globalIgnores } from 'eslint/config' export default tseslint.config([ - globalIgnores(['dist']), + globalIgnores(['dist', 'coverage']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/frontend-typescript/package-lock.json b/frontend-typescript/package-lock.json index b1e1696d..7c2fc633 100644 --- a/frontend-typescript/package-lock.json +++ b/frontend-typescript/package-lock.json @@ -22,8 +22,10 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.9.1", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "yaml": "^2.9.1" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -2098,6 +2100,15 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2111,6 +2122,24 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2118,6 +2147,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.5.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.1.tgz", @@ -2132,7 +2176,6 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2148,6 +2191,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -2419,6 +2468,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vercel/analytics": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", @@ -2806,6 +2861,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2945,6 +3010,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2979,6 +3054,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -3030,6 +3145,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3108,7 +3233,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -3129,7 +3253,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3150,6 +3273,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3194,6 +3330,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3546,6 +3695,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3576,6 +3735,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3971,6 +4136,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3991,6 +4196,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4079,6 +4294,46 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4112,6 +4367,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4122,6 +4387,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4605,6 +4882,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4700,100 +4987,718 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node_modules/mdast-util-to-markdown": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", + "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "license": "MIT", "dependencies": { - "minipass": "^7.1.2" + "@types/mdast": "^4.0.0" }, - "engines": { - "node": ">= 18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" } }, "node_modules/moment": { @@ -4809,7 +5714,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4914,6 +5818,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5120,6 +6049,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5194,6 +6133,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5256,6 +6222,39 @@ "node": ">=8" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5452,6 +6451,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5520,6 +6529,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -5609,6 +6632,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5862,6 +6903,26 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5933,6 +6994,93 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -5974,6 +7122,34 @@ "punycode": "^2.1.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -6420,6 +7596,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6432,6 +7623,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend-typescript/package.json b/frontend-typescript/package.json index 9010adc8..a59b7d8c 100644 --- a/frontend-typescript/package.json +++ b/frontend-typescript/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:ci": "node scripts/check-lint-baseline.mjs", "preview": "vite preview", "test": "vitest", "coverage": "vitest run --coverage" @@ -26,8 +27,10 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.9.1", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "yaml": "^2.9.1" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/frontend-typescript/scripts/check-lint-baseline.mjs b/frontend-typescript/scripts/check-lint-baseline.mjs new file mode 100644 index 00000000..70b6ef9d --- /dev/null +++ b/frontend-typescript/scripts/check-lint-baseline.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { ESLint } from 'eslint' + +// Ratchet: fail if lint problems exceed this count. Lower it as problems +// get fixed on touch — never raise it to paper over a new regression. +const BASELINE = 87 + +const eslint = new ESLint() +const results = await eslint.lintFiles(['.']) +const total = results.reduce((sum, r) => sum + r.errorCount + r.warningCount, 0) + +if (total > BASELINE) { + console.error( + `Lint problems: ${total} (baseline: ${BASELINE}). This introduces new lint errors/warnings — fix them before merging.`, + ) + process.exit(1) +} + +if (total < BASELINE) { + console.log( + `Lint problems: ${total} (baseline: ${BASELINE}). Lower BASELINE in scripts/check-lint-baseline.mjs to ${total} to lock in the improvement.`, + ) +} + +console.log(`Lint check passed: ${total} problem(s), baseline ${BASELINE}.`) diff --git a/frontend-typescript/src/App.tsx b/frontend-typescript/src/App.tsx index c746e89b..35d6b764 100644 --- a/frontend-typescript/src/App.tsx +++ b/frontend-typescript/src/App.tsx @@ -2,8 +2,11 @@ import { Routes, Route, Navigate, BrowserRouter, Outlet } from "react-router-dom import { useAuth, AuthProvider } from "./context/AuthContext"; import { AiChatProvider } from "./context/AiChatContext"; import Login from "./pages/Login"; -import LandingPage from "./pages/LandingPage"; +import LandingPage, { FounderSection, ContactSection } from "./pages/LandingPage"; import LegalPage from "./pages/Legal"; +import PublicLayout from "./components/site/PublicLayout"; +import HowItWorksPage from "./pages/site/HowItWorks"; +import SecurityPage from "./pages/site/Security"; import Dashboard from "./pages/Dashboard/Dashboard"; import { JSX } from "react"; import Register from "./pages/Register"; @@ -44,8 +47,15 @@ export default function App() { - } /> - } /> + }> + } /> + } /> + } /> + } /> + {/* Reuse existing content until the dedicated pages are ready. */} + } /> + } /> + } /> } /> } /> diff --git a/frontend-typescript/src/components/site/PublicLayout.test.tsx b/frontend-typescript/src/components/site/PublicLayout.test.tsx new file mode 100644 index 00000000..c49c5e3c --- /dev/null +++ b/frontend-typescript/src/components/site/PublicLayout.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import PublicLayout from "./PublicLayout"; + +function HowItWorksStub() { + return ( +
+

How it works intro

+
Five steps section
+
+ ); +} + +function renderAt(path: string) { + return render( + + + }> + Home page} /> + } /> + Privacy section} + /> + + + , + ); +} + +describe("PublicLayout", () => { + it("exposes all nav links and Request Demo once the mobile toggle is opened", async () => { + renderAt("/"); + + await userEvent.click( + screen.getByRole("button", { name: "Toggle navigation menu" }), + ); + + const mobileNav = within(screen.getByRole("navigation", { name: "Mobile" })); + expect(mobileNav.getByRole("link", { name: "How it works" })).toBeInTheDocument(); + expect(mobileNav.getByRole("link", { name: "Security" })).toBeInTheDocument(); + expect(mobileNav.getByRole("link", { name: "About" })).toBeInTheDocument(); + expect(mobileNav.getByRole("link", { name: "Contact" })).toBeInTheDocument(); + expect(mobileNav.getByRole("link", { name: "Request Demo" })).toBeInTheDocument(); + }); + + it("scrolls to the section named by the URL hash", () => { + const scrollIntoViewSpy = vi.fn(); + vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation( + scrollIntoViewSpy, + ); + + renderAt("/how-it-works#five-steps"); + + expect(scrollIntoViewSpy).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it("links the privacy policy and terms footer entries to /legal", () => { + renderAt("/"); + + expect(screen.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute( + "href", + "/legal#privacy", + ); + expect(screen.getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/legal#terms", + ); + }); + + it("scrolls to the legal anchor when a footer link is followed from another page", async () => { + const scrolledIds: string[] = []; + vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function ( + this: Element, + ) { + scrolledIds.push(this.id); + }); + + renderAt("/how-it-works"); + await userEvent.click(screen.getByRole("link", { name: "Privacy Policy" })); + + expect(screen.getByText("Privacy section")).toBeInTheDocument(); + expect(scrolledIds).toContain("privacy"); + + vi.restoreAllMocks(); + }); +}); diff --git a/frontend-typescript/src/components/site/PublicLayout.tsx b/frontend-typescript/src/components/site/PublicLayout.tsx new file mode 100644 index 00000000..3527a955 --- /dev/null +++ b/frontend-typescript/src/components/site/PublicLayout.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react"; +import { Link, Outlet, useLocation } from "react-router-dom"; +import { Menu, X, Github, Linkedin } from "lucide-react"; +import ogfIcon from "@/assets/logos/ogf-icon.svg"; +import { brand } from "@/lib/brand"; + +const NAV_LINKS = [ + { to: "/how-it-works", label: "How it works" }, + { to: "/security", label: "Security" }, + { to: "/about", label: "About" }, + { to: "/contact", label: "Contact" }, +]; + +const FOOTER_LINKS = [ + { to: "/", label: "Home" }, + { to: "/how-it-works", label: "How it works" }, + { to: "/security", label: "Security" }, + { to: "/about", label: "About" }, + { to: "/contact", label: "Contact" }, +]; + +function useHashScroll() { + const location = useLocation(); + + useEffect(() => { + if (!location.hash) return; + const id = location.hash.slice(1); + document.getElementById(id)?.scrollIntoView(); + }, [location.pathname, location.hash]); +} + +function RequestDemoButton({ className = "" }: { className?: string }) { + return ( + + Request Demo + + ); +} + +function PublicHeader() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+
+ + + + Open Grant Flow + + +
+ + + +
+
+ {mobileOpen && ( + + )} +
+ ); +} + +function PublicFooter() { + return ( +
+
+
+

+ Open Grant Flow +

+

+ Open-source grant financial management platform. +

+
+
+ + +
+ + Privacy Policy + + + Terms + +
+

+ © {new Date().getFullYear()} Open Grant Flow +

+
+
+
+ ); +} + +export default function PublicLayout() { + useHashScroll(); + + return ( +
+ + + +
+ ); +} diff --git a/frontend-typescript/src/components/site/SectionVisibility.tsx b/frontend-typescript/src/components/site/SectionVisibility.tsx new file mode 100644 index 00000000..3e9a0ea4 --- /dev/null +++ b/frontend-typescript/src/components/site/SectionVisibility.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import type { SiteSection } from "@/lib/siteContent"; +import { TbcMarker } from "./TbcMarker"; + +export function SectionVisibility({ section, children }: { + section: SiteSection | null; + children: (section: SiteSection) => ReactNode; +}) { + if (!section) return null; + const content = children(section); + return section.status === "draft" ? {content} : content; +} diff --git a/frontend-typescript/src/components/site/SiteMarkdown.test.tsx b/frontend-typescript/src/components/site/SiteMarkdown.test.tsx new file mode 100644 index 00000000..44db3c7f --- /dev/null +++ b/frontend-typescript/src/components/site/SiteMarkdown.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { SiteMarkdown } from "./SiteMarkdown"; + +function renderMarkdown(body: string) { + return render( + + {body} + , + ); +} + +describe("SiteMarkdown", () => { + it("renders no element for raw HTML in the body", () => { + const { container } = renderMarkdown("Before after."); + + expect(container.querySelector("script")).not.toBeInTheDocument(); + }); + + it("renders an internal link as a router link", () => { + renderMarkdown("[Contact us](/contact)"); + + const link = screen.getByRole("link", { name: "Contact us" }); + expect(link).toHaveAttribute("href", "/contact"); + expect(link).not.toHaveAttribute("target"); + }); + + it("gives an external link rel=noopener noreferrer", () => { + renderMarkdown("[Example](https://example.com)"); + + const link = screen.getByRole("link", { name: "Example" }); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/frontend-typescript/src/components/site/SiteMarkdown.tsx b/frontend-typescript/src/components/site/SiteMarkdown.tsx new file mode 100644 index 00000000..892326d2 --- /dev/null +++ b/frontend-typescript/src/components/site/SiteMarkdown.tsx @@ -0,0 +1,23 @@ +import ReactMarkdown, { type Components } from "react-markdown"; +import { Link } from "react-router-dom"; + +const components: Components = { + a: ({ href = "", children, ...props }) => { + if (href.startsWith("/") || href.startsWith("#")) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, +}; + +export function SiteMarkdown({ children }: { children: string }) { + return {children}; +} diff --git a/frontend-typescript/src/components/site/TbcMarker.tsx b/frontend-typescript/src/components/site/TbcMarker.tsx new file mode 100644 index 00000000..e8802916 --- /dev/null +++ b/frontend-typescript/src/components/site/TbcMarker.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +export function TbcMarker({ children }: { children: ReactNode }) { + return ( +
+ + TBC + + {children} +
+ ); +} diff --git a/frontend-typescript/src/content/site/README.md b/frontend-typescript/src/content/site/README.md new file mode 100644 index 00000000..6b4180e8 --- /dev/null +++ b/frontend-typescript/src/content/site/README.md @@ -0,0 +1,49 @@ +# Public-site content + +Every public page's copy lives here as one markdown file per section, never in +page component source. A section file has YAML frontmatter followed by an +optional markdown body. + +```markdown +--- +id: how-it-works.five-steps +page: how-it-works +anchor: five-steps +title: Five steps +status: published +personas: [grantee, funder] # optional +items: # optional, structured entries + - title: Budget + body: Create a structured project budget. +--- +Optional prose body, rendered as markdown (no raw HTML). +``` + +## Required fields + +- `id` — globally unique, stable, in the form `.`. +- `page` — one of `home`, `how-it-works`, `security`, `about`, `contact`, `legal`. +- `anchor` — a URL fragment, unique within its page. The section's canonical + URL is `#`. +- `title` — the section heading as shown to visitors. +- `status` — `published` or `draft`. + +## Optional fields + +- `personas` — a subset of `grantee`, `funder`, `technical`. +- `items` — a list of structured entries (cards, steps, quotes, FAQs). Each + item needs a `title` or `quote`, and a `body` or `attribution`. + +## IDs and anchors are stable + +Once published, a section's `id` and `anchor` don't change when only its +wording changes — downstream consumers (including the site assistant) cite +sections by both. Renaming or removing either is a breaking content change. + +## Draft lifecycle + +A section ships as `status: draft` when its wording isn't confirmed yet. Draft +sections never render in a production build. In development and preview +builds they render with a visible "TBC" marker so authors and reviewers can +see what's still pending. Flip `status` to `published` once the wording is +confirmed — no code change needed. diff --git a/frontend-typescript/src/content/site/how-it-works/five-steps.md b/frontend-typescript/src/content/site/how-it-works/five-steps.md new file mode 100644 index 00000000..08485440 --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/five-steps.md @@ -0,0 +1,18 @@ +--- +id: how-it-works.five-steps +page: how-it-works +anchor: five-steps +title: Five steps +status: published +items: + - title: Budget + body: Create a structured project budget, with optional AI assistance to build it from plain language. + - title: Track + body: Record spending against the approved budget as it happens. + - title: Receipts + body: Keep receipts and supporting documents connected to every transaction. + - title: Report + body: Generate a donor-ready financial report without rebuilding a spreadsheet from scratch. + - title: Audit-ready + body: Maintain a transparent financial history that simplifies audits and reviews. +--- diff --git a/frontend-typescript/src/content/site/how-it-works/intro.md b/frontend-typescript/src/content/site/how-it-works/intro.md new file mode 100644 index 00000000..1b6e7116 --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/intro.md @@ -0,0 +1,8 @@ +--- +id: how-it-works.intro +page: how-it-works +anchor: intro +title: One continuous financial record, from budget to report +status: published +--- +Every grant creates another spreadsheet, another donor template, another round of reconciliation. Open Grant Flow gives nonprofits and funders one structured record instead. diff --git a/frontend-typescript/src/content/site/how-it-works/roadmap.md b/frontend-typescript/src/content/site/how-it-works/roadmap.md new file mode 100644 index 00000000..6752b1ba --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/roadmap.md @@ -0,0 +1,8 @@ +--- +id: how-it-works.roadmap +page: how-it-works +anchor: roadmap +title: What's next +status: draft +--- +Snap a receipt on your phone and have it recognised, categorised and matched to the right budget line. Pilot Partners help decide what we build first. diff --git a/frontend-typescript/src/content/site/how-it-works/sector-validation.md b/frontend-typescript/src/content/site/how-it-works/sector-validation.md new file mode 100644 index 00000000..2c4f9923 --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/sector-validation.md @@ -0,0 +1,17 @@ +--- +id: how-it-works.sector-validation +page: how-it-works +anchor: sector-validation +title: From conversations across the sector +status: published +items: + - quote: Even sophisticated internal systems can still require exporting files to grantees and bringing them back. + attribution: Former UK fund CEO + - quote: Multiple platforms solve different pieces, but not the basic cross-organisation workflow. + attribution: International nonprofit leader + - quote: A donor portal can digitise the funder side while the grantee still keeps separate local records. + attribution: Nonprofit / fund adviser + - title: From a conversation with a local nonprofit leader + body: One leader of a small local nonprofit told us they went looking for an alternative and couldn't find one that fit — a sign the willingness is already there, once the right tool exists. +--- +The opportunity is interoperability, not another closed portal. diff --git a/frontend-typescript/src/content/site/how-it-works/survey-evidence.md b/frontend-typescript/src/content/site/how-it-works/survey-evidence.md new file mode 100644 index 00000000..c2085ffe --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/survey-evidence.md @@ -0,0 +1,10 @@ +--- +id: how-it-works.survey-evidence +page: how-it-works +anchor: survey-evidence +title: What we're hearing from the sector +status: published +--- +In our exploratory survey of 18 nonprofits, 15 said donor reporting requirements differ between funders, and 11 estimated that donor-specific reporting adds one to three days each time. Our goal is to bring that down to hours. + +*Early, directional evidence from a small survey. Not a representative sample of the sector.* diff --git a/frontend-typescript/src/content/site/how-it-works/who-funders.md b/frontend-typescript/src/content/site/how-it-works/who-funders.md new file mode 100644 index 00000000..3603ee79 --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/who-funders.md @@ -0,0 +1,15 @@ +--- +id: how-it-works.who-funders +page: how-it-works +anchor: who-funders +title: For funders +status: published +personas: [funder] +items: + - title: Clear portfolio view + body: See your grantees, their funded budgets, reports and attached receipts in one dashboard. + - title: Multi-currency visibility + body: Follow grants funded in different currencies without reconciling by hand. + - title: Lighter reporting burden + body: Better information for you, and less duplicated effort for the organisations you support. +--- diff --git a/frontend-typescript/src/content/site/how-it-works/who-grantees.md b/frontend-typescript/src/content/site/how-it-works/who-grantees.md new file mode 100644 index 00000000..e54180ef --- /dev/null +++ b/frontend-typescript/src/content/site/how-it-works/who-grantees.md @@ -0,0 +1,15 @@ +--- +id: how-it-works.who-grantees +page: how-it-works +anchor: who-grantees +title: For grantees +status: published +personas: [grantee] +items: + - title: Less time on administration + body: Enter your budget and spending once and reuse the record when you report. + - title: Evidence in one place + body: Receipts and supporting documents stay connected to every transaction. + - title: Free for grassroots organisations + body: The smallest organisations have the least capacity to spare, so they will never be priced out. +--- diff --git a/frontend-typescript/src/content/site/security/ai-providers.md b/frontend-typescript/src/content/site/security/ai-providers.md new file mode 100644 index 00000000..f87b0cc9 --- /dev/null +++ b/frontend-typescript/src/content/site/security/ai-providers.md @@ -0,0 +1,8 @@ +--- +id: security.ai-providers +page: security +anchor: ai-providers +title: AI providers and data +status: published +--- +AI assistance is optional. If you use it, you can bring your own AI provider key (BYOK) rather than depend on ours, so you choose who processes that data. diff --git a/frontend-typescript/src/content/site/security/data-location.md b/frontend-typescript/src/content/site/security/data-location.md new file mode 100644 index 00000000..d0b115cd --- /dev/null +++ b/frontend-typescript/src/content/site/security/data-location.md @@ -0,0 +1,8 @@ +--- +id: security.data-location +page: security +anchor: data-location +title: Where your data is stored +status: draft +--- +Hosting region and data storage details are being finalised and will be published here once confirmed. diff --git a/frontend-typescript/src/content/site/security/gdpr.md b/frontend-typescript/src/content/site/security/gdpr.md new file mode 100644 index 00000000..21fe633b --- /dev/null +++ b/frontend-typescript/src/content/site/security/gdpr.md @@ -0,0 +1,8 @@ +--- +id: security.gdpr +page: security +anchor: gdpr +title: GDPR compliance +status: draft +--- +The platform is designed to support GDPR-compliant deployment and strong data-protection requirements, and we are in the process of pursuing the security accreditations that will confirm our operations meet that standard. diff --git a/frontend-typescript/src/content/site/security/high-risk.md b/frontend-typescript/src/content/site/security/high-risk.md new file mode 100644 index 00000000..6ef181df --- /dev/null +++ b/frontend-typescript/src/content/site/security/high-risk.md @@ -0,0 +1,10 @@ +--- +id: security.high-risk +page: security +anchor: high-risk +title: Talk to us about your security needs +status: published +--- +Tell us about any high-risk context when you [request a demo](/contact). We are glad to talk through your specific security needs before you share anything with us. + +Have a security question we haven't answered? Ask us directly when you request a demo. diff --git a/frontend-typescript/src/content/site/security/intro.md b/frontend-typescript/src/content/site/security/intro.md new file mode 100644 index 00000000..aafe14b4 --- /dev/null +++ b/frontend-typescript/src/content/site/security/intro.md @@ -0,0 +1,8 @@ +--- +id: security.intro +page: security +anchor: intro +title: Handled with the care we'd want for our own data +status: published +--- +For many of the organisations we work with, protecting information is tied to the safety of the people and communities behind the work. We build with that in mind from the start. diff --git a/frontend-typescript/src/content/site/security/self-hosting.md b/frontend-typescript/src/content/site/security/self-hosting.md new file mode 100644 index 00000000..5a1eb44b --- /dev/null +++ b/frontend-typescript/src/content/site/security/self-hosting.md @@ -0,0 +1,8 @@ +--- +id: security.self-hosting +page: security +anchor: self-hosting +title: Self-hosting +status: published +--- +Open Grant Flow is open source. You can use our hosted platform, or [self-host it](https://github.com/arutsh/OpenGrantFlow) if you prefer to keep everything in-house. diff --git a/frontend-typescript/src/lib/siteContent.test.ts b/frontend-typescript/src/lib/siteContent.test.ts new file mode 100644 index 00000000..cac05c1b --- /dev/null +++ b/frontend-typescript/src/lib/siteContent.test.ts @@ -0,0 +1,152 @@ +import { buildSiteContent } from "./siteContent"; + +const PUBLISHED = `--- +id: home.hero +page: home +anchor: hero +title: Hero +status: published +--- +Hero body.`; + +const DRAFT = `--- +id: security.data-location +page: security +anchor: data-location +title: Data location +status: draft +--- +TBC data location.`; + +describe("buildSiteContent", () => { + it("throws naming the file and the missing field", () => { + expect(() => + buildSiteContent({ + "/a.md": `--- +page: home +anchor: hero +title: Hero +status: published +--- +Body`, + }), + ).toThrow(/\/a\.md: missing required field "id"/); + }); + + it("throws on an invalid page value", () => { + expect(() => + buildSiteContent({ + "/a.md": `--- +id: bogus.hero +page: not-a-page +anchor: hero +title: Hero +status: published +--- +Body`, + }), + ).toThrow(/\/a\.md: invalid "page" value "not-a-page"/); + }); + + it("throws on an invalid status value", () => { + expect(() => + buildSiteContent({ + "/a.md": `--- +id: home.hero +page: home +anchor: hero +title: Hero +status: pending +--- +Body`, + }), + ).toThrow(/\/a\.md: invalid "status" value "pending"/); + }); + + it("throws on an invalid personas value", () => { + expect(() => + buildSiteContent({ + "/a.md": `--- +id: home.hero +page: home +anchor: hero +title: Hero +status: published +personas: [grantee, astronaut] +--- +Body`, + }), + ).toThrow(/\/a\.md: invalid "personas" value/); + }); + + it("throws naming both files on a duplicate id", () => { + expect(() => + buildSiteContent({ + "/a.md": PUBLISHED, + "/b.md": PUBLISHED.replace("anchor: hero", "anchor: other"), + }), + ).toThrow(/duplicate id "home\.hero" in \/a\.md, \/b\.md/); + }); + + it("throws naming both files on a duplicate anchor within the same page", () => { + expect(() => + buildSiteContent({ + "/a.md": PUBLISHED, + "/b.md": PUBLISHED.replace("id: home.hero", "id: home.hero-2"), + }), + ).toThrow(/duplicate anchor "hero" on page "home" in \/a\.md, \/b\.md/); + }); + + it("hides draft sections when isProd is true", () => { + const { getSection } = buildSiteContent({ "/a.md": DRAFT }, { isProd: true }); + + expect(getSection("security.data-location")).toBeNull(); + }); + + it("shows draft sections when isProd is false", () => { + const { getSection } = buildSiteContent({ "/a.md": DRAFT }, { isProd: false }); + + const section = getSection("security.data-location"); + expect(section?.status).toBe("draft"); + expect(section?.title).toBe("Data location"); + }); + + it("throws on an unknown section id", () => { + const { getSection } = buildSiteContent({ "/a.md": PUBLISHED }); + + expect(() => getSection("home.missing")).toThrow( + /Unknown site content section "home\.missing"/, + ); + }); + + it("returns published sections unchanged in production", () => { + const { getSection } = buildSiteContent({ "/a.md": PUBLISHED }, { isProd: true }); + + const section = getSection("home.hero"); + expect(section?.status).toBe("published"); + expect(section?.body).toBe("Hero body."); + }); + + it("parses a file saved with a UTF-8 byte order mark", () => { + const { getSection } = buildSiteContent({ "/a.md": `\uFEFF${PUBLISHED}` }); + + expect(getSection("home.hero")?.title).toBe("Hero"); + }); + + it("reports fence and YAML errors alongside other files' validation errors", () => { + let message = ""; + try { + buildSiteContent({ + "/no-fence.md": "id: home.hero\nBody", + "/bad-yaml.md": "---\nid: [unclosed\n---\nBody", + "/no-id.md": PUBLISHED.replace("id: home.hero\n", ""), + }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toMatch(/\/no-fence\.md: missing YAML frontmatter fence/); + expect(message).toMatch(/\/bad-yaml\.md: /); + expect(message).toMatch(/\/no-id\.md: missing required field "id"/); + }); +}); diff --git a/frontend-typescript/src/lib/siteContent.ts b/frontend-typescript/src/lib/siteContent.ts new file mode 100644 index 00000000..c6d68762 --- /dev/null +++ b/frontend-typescript/src/lib/siteContent.ts @@ -0,0 +1,222 @@ +import { parse as parseYaml } from "yaml"; + +export type SitePage = + | "home" + | "how-it-works" + | "security" + | "about" + | "contact" + | "legal"; + +export type SitePersona = "grantee" | "funder" | "technical"; + +export type SiteSectionStatus = "published" | "draft"; + +export interface SiteContentItem { + title?: string; + quote?: string; + body?: string; + attribution?: string; + [key: string]: unknown; +} + +export interface SiteSection { + id: string; + page: SitePage; + anchor: string; + title: string; + status: SiteSectionStatus; + personas?: SitePersona[]; + items?: SiteContentItem[]; + body: string; +} + +const PAGES: SitePage[] = [ + "home", + "how-it-works", + "security", + "about", + "contact", + "legal", +]; +const STATUSES: SiteSectionStatus[] = ["published", "draft"]; +const PERSONAS: SitePersona[] = ["grantee", "funder", "technical"]; + +interface ParsedFile { + path: string; + frontmatter: Record; + body: string; +} + +function splitFrontmatter(raw: string): { frontmatter: unknown; body: string } { + const withoutBom = raw.replace(/^\uFEFF/, ""); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(withoutBom); + if (!match) { + throw new Error("missing YAML frontmatter fence"); + } + const [, frontmatterYaml, body] = match; + return { frontmatter: parseYaml(frontmatterYaml), body: body.trim() }; +} + +function validateItems( + items: unknown, + path: string, + errors: string[], +): void { + if (!Array.isArray(items)) { + errors.push(`${path}: "items" must be a list`); + return; + } + items.forEach((item, index) => { + if (typeof item !== "object" || item === null) { + errors.push(`${path}: items[${index}] must be an object`); + return; + } + const candidate = item as SiteContentItem; + if (!candidate.title && !candidate.quote) { + errors.push(`${path}: items[${index}] must have a "title" or "quote"`); + } + if (!candidate.body && !candidate.attribution) { + errors.push(`${path}: items[${index}] must have a "body" or "attribution"`); + } + }); +} + +function validateFrontmatter( + frontmatter: Record, + path: string, + errors: string[], +): void { + for (const field of ["id", "page", "anchor", "title", "status"] as const) { + if (!frontmatter[field]) { + errors.push(`${path}: missing required field "${field}"`); + } + } + if ( + frontmatter.page !== undefined && + !PAGES.includes(frontmatter.page as SitePage) + ) { + errors.push(`${path}: invalid "page" value "${String(frontmatter.page)}"`); + } + if ( + frontmatter.status !== undefined && + !STATUSES.includes(frontmatter.status as SiteSectionStatus) + ) { + errors.push(`${path}: invalid "status" value "${String(frontmatter.status)}"`); + } + if (frontmatter.personas !== undefined) { + const personas = frontmatter.personas; + const valid = + Array.isArray(personas) && + personas.every((persona) => PERSONAS.includes(persona as SitePersona)); + if (!valid) { + errors.push( + `${path}: invalid "personas" value "${JSON.stringify(frontmatter.personas)}"`, + ); + } + } + if (frontmatter.items !== undefined) { + validateItems(frontmatter.items, path, errors); + } +} + +function findDuplicates(parsed: ParsedFile[], errors: string[]): void { + const idToPaths = new Map(); + const anchorToPaths = new Map(); + + for (const file of parsed) { + const id = file.frontmatter.id as string | undefined; + const page = file.frontmatter.page as string | undefined; + const anchor = file.frontmatter.anchor as string | undefined; + if (id) { + idToPaths.set(id, [...(idToPaths.get(id) ?? []), file.path]); + } + if (page && anchor) { + const key = `${page}::${anchor}`; + anchorToPaths.set(key, [...(anchorToPaths.get(key) ?? []), file.path]); + } + } + + for (const [id, paths] of idToPaths) { + if (paths.length > 1) { + errors.push(`duplicate id "${id}" in ${paths.join(", ")}`); + } + } + for (const [key, paths] of anchorToPaths) { + if (paths.length > 1) { + const [page, anchor] = key.split("::"); + errors.push(`duplicate anchor "${anchor}" on page "${page}" in ${paths.join(", ")}`); + } + } +} + +export function buildSiteContent( + rawFiles: Record, + options: { isProd?: boolean } = {}, +) { + const errors: string[] = []; + const parsed: ParsedFile[] = []; + + for (const [path, raw] of Object.entries(rawFiles)) { + let frontmatter: unknown; + let body: string; + try { + ({ frontmatter, body } = splitFrontmatter(raw)); + } catch (error) { + errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + if (typeof frontmatter !== "object" || frontmatter === null) { + errors.push(`${path}: frontmatter must be a YAML mapping`); + continue; + } + parsed.push({ path, frontmatter: frontmatter as Record, body }); + } + + for (const file of parsed) { + validateFrontmatter(file.frontmatter, file.path, errors); + } + findDuplicates(parsed, errors); + + if (errors.length > 0) { + throw new Error(`Site content validation failed:\n${errors.join("\n")}`); + } + + const sections = new Map(); + for (const file of parsed) { + const fm = file.frontmatter; + sections.set(fm.id as string, { + id: fm.id as string, + page: fm.page as SitePage, + anchor: fm.anchor as string, + title: fm.title as string, + status: fm.status as SiteSectionStatus, + personas: fm.personas as SitePersona[] | undefined, + items: fm.items as SiteContentItem[] | undefined, + body: file.body, + }); + } + + function getSection(id: string): SiteSection | null { + const section = sections.get(id); + if (!section) { + throw new Error(`Unknown site content section "${id}"`); + } + const isProd = options.isProd ?? import.meta.env.PROD; + if (section.status === "draft" && isProd) { + return null; + } + return section; + } + + return { getSection, sections }; +} + +const RAW_FILES = import.meta.glob( + ["../content/site/**/*.md", "!../content/site/README.md"], + { eager: true, query: "?raw", import: "default" }, +) as Record; + +const siteContent = buildSiteContent(RAW_FILES); + +export const getSection = siteContent.getSection; diff --git a/frontend-typescript/src/pages/LandingPage.test.tsx b/frontend-typescript/src/pages/LandingPage.test.tsx index 722dc01b..3f04322d 100644 --- a/frontend-typescript/src/pages/LandingPage.test.tsx +++ b/frontend-typescript/src/pages/LandingPage.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/context/AuthContext"; +import PublicLayout from "@/components/site/PublicLayout"; import LandingPage from "./LandingPage"; function renderAt(path: string) { @@ -9,7 +10,9 @@ function renderAt(path: string) { - } /> + }> + } /> + Dashboard Page} /> @@ -37,14 +40,14 @@ describe("LandingPage", () => { ).toBeInTheDocument(); }); - it("offers a Request Demo CTA in the nav that points to the contact section", () => { + it("offers a Request Demo CTA in the shared nav that points to the contact page", () => { renderAt("/"); const requestDemoLinks = screen.getAllByRole("link", { name: "Request Demo", }); expect(requestDemoLinks.length).toBeGreaterThan(0); - expect(requestDemoLinks[0]).toHaveAttribute("href", "#contact"); + expect(requestDemoLinks[0]).toHaveAttribute("href", "/contact"); }); it("renders the product demo embed", () => { diff --git a/frontend-typescript/src/pages/LandingPage.tsx b/frontend-typescript/src/pages/LandingPage.tsx index 6d753862..9fc9f2f6 100644 --- a/frontend-typescript/src/pages/LandingPage.tsx +++ b/frontend-typescript/src/pages/LandingPage.tsx @@ -8,22 +8,11 @@ import { FileText, ShieldCheck, ArrowRight, - Github, - Linkedin, User, } from "lucide-react"; -import ogfIcon from "@/assets/logos/ogf-icon.svg"; import productMockup from "@/assets/logos/opengrantflow-mockup.png"; import { brand } from "@/lib/brand"; -const NAV_LINKS = [ - { href: "#vision", label: "Vision" }, - { href: "#platform", label: "Platform" }, - { href: "#founding-partners", label: "Founding Partners" }, - { href: "#about", label: "About" }, - { href: "#contact", label: "Contact" }, -]; - const TONE_COLORS = { grey: { border: "#94A3B8", text: "#64748B" }, orange: { border: brand.gold, text: "#92702B" }, @@ -158,45 +147,6 @@ function Kicker({ children }: { children: React.ReactNode }) { ); } -function Nav() { - return ( -
-
-
- - - Open Grant Flow - -
-
- - - Request Demo - -
-
-
- ); -} - function Hero() { return (
@@ -508,7 +458,7 @@ function OpenSourceSection() { ); } -function FounderSection() { +export function FounderSection() { return (
About @@ -605,7 +555,7 @@ function PartnerSection() { ); } -function ContactSection() { +export function ContactSection() { const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(false); @@ -790,52 +740,6 @@ function ContactSection() { ); } -function Footer() { - return ( - - ); -} - export default function LandingPage() { const { isAuthenticated, loading } = useAuth(); @@ -843,8 +747,7 @@ export default function LandingPage() { if (isAuthenticated) return ; return ( -
-
+ ); } diff --git a/frontend-typescript/src/pages/Legal.tsx b/frontend-typescript/src/pages/Legal.tsx index efc521ed..4389e61e 100644 --- a/frontend-typescript/src/pages/Legal.tsx +++ b/frontend-typescript/src/pages/Legal.tsx @@ -1,183 +1,168 @@ -import { Link } from "react-router-dom"; import { brand } from "@/lib/brand"; -import ogfIcon from "@/assets/logos/ogf-icon.svg"; const LAST_UPDATED = "August 2026"; export default function LegalPage() { return ( -
-
-
- - - - Open Grant Flow - - -
-
- -
-
-

Privacy Policy

-

- Last updated: {LAST_UPDATED} +

+
+

Privacy Policy

+

+ Last updated: {LAST_UPDATED} +

+ +
+

+ Open Grant Flow ("we", "us") is an open-source grant financial + management platform, currently in active development. This + policy explains what information we collect and how we use it.

-
+
+

Legal status

+

+ Open Grant Flow is operated by its maintainer as an + open-source project and is not yet a registered legal entity + (company or nonprofit). This may change as the project + develops. +

+
+ +
+

Information we collect

+

+ When you submit our contact form, we collect your name, + organisation, email address, role and message. If you use our + hosted platform, we collect the account and budget information + needed to provide the service. +

+
+ +
+

How we use it

+

+ We use this information solely to respond to your enquiry, to + operate the hosted platform, and to improve Open Grant Flow. + We do not sell your information to third parties. +

+
+ +
+

Self-hosting

+

+ Open Grant Flow is open source. If you self-host the + platform, your data stays on your own infrastructure and this + policy does not apply to it. +

+
+ +
+

How long we keep it

- Open Grant Flow ("we", "us") is an open-source grant financial - management platform, currently in active development. This - policy explains what information we collect and how we use it. + We retain your information only for as long as needed to + provide the service, respond to your enquiry, or meet legal + and institutional record-keeping obligations. We don't yet + publish a fixed retention schedule; this is being finalized + as the platform matures.

+
-
-

Legal status

-

- Open Grant Flow is operated by its maintainer as an - open-source project and is not yet a registered legal entity - (company or nonprofit). This may change as the project - develops. -

-
- -
-

Information we collect

-

- When you submit our contact form, we collect your name, - organisation, email address, role and message. If you use our - hosted platform, we collect the account and budget information - needed to provide the service. -

-
- -
-

How we use it

-

- We use this information solely to respond to your enquiry, to - operate the hosted platform, and to improve Open Grant Flow. - We do not sell your information to third parties. -

-
- -
-

Self-hosting

-

- Open Grant Flow is open source. If you self-host the - platform, your data stays on your own infrastructure and this - policy does not apply to it. -

-
- -
-

How long we keep it

-

- We retain your information only for as long as needed to - provide the service, respond to your enquiry, or meet legal - and institutional record-keeping obligations. We don't yet - publish a fixed retention schedule; this is being finalized - as the platform matures. -

-
- -
-

Third-party processors

-

- We use a small number of third-party services to operate the - hosted platform: Hetzner (infrastructure, EU), Mailjet and - MailerSend (transactional email, EU), Anthropic (AI-assisted - features, when you supply your own API key — this involves a - transfer of data outside the EU/EEA to the US), and Grafana - Cloud (operational monitoring). Each only receives the data - needed to provide its service. -

-
- -
-

Contact

-

- Questions about this policy can be sent to{" "} - - privacy@opengrantflow.com - {" "} - or through our{" "} - - contact form - - . -

-
+
+

Third-party processors

+

+ We use a small number of third-party services to operate the + hosted platform: Hetzner (infrastructure, EU), Mailjet and + MailerSend (transactional email, EU), Anthropic (AI-assisted + features, when you supply your own API key — this involves a + transfer of data outside the EU/EEA to the US), and Grafana + Cloud (operational monitoring). Each only receives the data + needed to provide its service. +

-
-
-

Terms of Service

-

- Last updated: {LAST_UPDATED} +

+

Contact

+

+ Questions about this policy can be sent to{" "} + + privacy@opengrantflow.com + {" "} + or through our{" "} + + contact form + + . +

+
+
+
+ +
+

Terms of Service

+

+ Last updated: {LAST_UPDATED} +

+ +
+

+ By using Open Grant Flow, you agree to these terms. If you do + not agree, please do not use the platform.

-
+
+

The service

+

+ Open Grant Flow is open-source software, currently in active + development. Features, availability and functionality may + change as the platform evolves. +

+
+ +
+

No warranty

+

+ The platform is provided "as is", without warranty of any + kind, express or implied, including during this active + development phase. +

+
+ +
+

Acceptable use

+

+ You agree to use Open Grant Flow only for lawful purposes and + not to misuse the platform or attempt to disrupt its + operation. +

+
+ +
+

Changes to these terms

- By using Open Grant Flow, you agree to these terms. If you do - not agree, please do not use the platform. + We may update these terms as the platform develops. Continued + use of Open Grant Flow after changes means you accept the + updated terms.

+
-
-

The service

-

- Open Grant Flow is open-source software, currently in active - development. Features, availability and functionality may - change as the platform evolves. -

-
- -
-

No warranty

-

- The platform is provided "as is", without warranty of any - kind, express or implied, including during this active - development phase. -

-
- -
-

Acceptable use

-

- You agree to use Open Grant Flow only for lawful purposes and - not to misuse the platform or attempt to disrupt its - operation. -

-
- -
-

Changes to these terms

-

- We may update these terms as the platform develops. Continued - use of Open Grant Flow after changes means you accept the - updated terms. -

-
- -
-

Contact

-

- Questions about these terms can be sent through our{" "} - - contact form - - . -

-
+
+

Contact

+

+ Questions about these terms can be sent through our{" "} + + contact form + + . +

-
- - + +
+ ); } diff --git a/frontend-typescript/src/pages/site/HowItWorks.test.tsx b/frontend-typescript/src/pages/site/HowItWorks.test.tsx new file mode 100644 index 00000000..ba58f2f6 --- /dev/null +++ b/frontend-typescript/src/pages/site/HowItWorks.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import HowItWorksPage from "./HowItWorks"; + +function renderPage() { + return render( + + + , + ); +} + +describe("HowItWorksPage", () => { + it("renders the intro and the five steps", () => { + renderPage(); + + expect( + screen.getByText("One continuous financial record, from budget to report"), + ).toBeInTheDocument(); + expect(screen.getByText("Budget")).toBeInTheDocument(); + expect(screen.getByText("Track")).toBeInTheDocument(); + expect(screen.getByText("Receipts")).toBeInTheDocument(); + expect(screen.getByText("Report")).toBeInTheDocument(); + expect(screen.getByText("Audit-ready")).toBeInTheDocument(); + }); + + it("shows role-attributed pull-quotes describing sector pain points", () => { + renderPage(); + + expect(screen.getByText("Former UK fund CEO")).toBeInTheDocument(); + expect(screen.getByText("International nonprofit leader")).toBeInTheDocument(); + expect(screen.getByText("Nonprofit / fund adviser")).toBeInTheDocument(); + expect( + screen.getByText(/Even sophisticated internal systems/), + ).toBeInTheDocument(); + }); + + it("presents the anecdote without quotation marks, visually distinguished from the quotes", () => { + renderPage(); + + const anecdoteText = screen.getByText(/One leader of a small local nonprofit/); + expect(anecdoteText.textContent).not.toMatch(/[“”"]/); + expect( + screen.getByText("From a conversation with a local nonprofit leader"), + ).toBeInTheDocument(); + }); + + it("shows a closing statement about the market opportunity", () => { + renderPage(); + + expect( + screen.getByText("The opportunity is interoperability, not another closed portal."), + ).toBeInTheDocument(); + }); + + it("does not attribute any quote to a named person or organization", () => { + renderPage(); + + const attributions = [ + "Former UK fund CEO", + "International nonprofit leader", + "Nonprofit / fund adviser", + ]; + attributions.forEach((role) => { + expect(screen.getByText(role)).toBeInTheDocument(); + }); + }); + + it("renders the who-it's-for sections for grantees and funders", () => { + renderPage(); + + expect(screen.getByText("For grantees")).toBeInTheDocument(); + expect(screen.getByText("Less time on administration")).toBeInTheDocument(); + expect(screen.getByText("For funders")).toBeInTheDocument(); + expect(screen.getByText("Clear portfolio view")).toBeInTheDocument(); + }); +}); diff --git a/frontend-typescript/src/pages/site/HowItWorks.tsx b/frontend-typescript/src/pages/site/HowItWorks.tsx new file mode 100644 index 00000000..c8da2901 --- /dev/null +++ b/frontend-typescript/src/pages/site/HowItWorks.tsx @@ -0,0 +1,197 @@ +import { getSection } from "@/lib/siteContent"; +import { SiteMarkdown } from "@/components/site/SiteMarkdown"; +import { SectionVisibility } from "@/components/site/SectionVisibility"; +import { brand } from "@/lib/brand"; + +export default function HowItWorksPage() { + const intro = getSection("how-it-works.intro"); + const fiveSteps = getSection("how-it-works.five-steps"); + const surveyEvidence = getSection("how-it-works.survey-evidence"); + const roadmap = getSection("how-it-works.roadmap"); + const whoGrantees = getSection("how-it-works.who-grantees"); + const whoFunders = getSection("how-it-works.who-funders"); + const sectorValidation = getSection("how-it-works.sector-validation"); + + const quotes = sectorValidation?.items?.filter((item) => item.quote) ?? []; + const anecdotes = sectorValidation?.items?.filter((item) => !item.quote) ?? []; + + return ( + <> + How it works · Open Grant Flow +
+ + {(intro) => ( +
+

+ {intro.title} +

+
+ {intro.body} +
+
+ )} +
+ + + {(fiveSteps) => ( +
+

+ {fiveSteps.title} +

+
    + {fiveSteps.items?.map((step, index) => ( +
  1. +

    + Step {index + 1} +

    +

    + {step.title} +

    +

    {step.body}

    +
  2. + ))} +
+
+ )} +
+ + + {(sectorValidation) => ( +
+
+

+ {sectorValidation.title} +

+ +
+ {quotes.map((quote, index) => ( +
+

+ “{quote.quote}” +

+

+ {quote.attribution} +

+
+ ))} +
+ + {anecdotes.map((anecdote, index) => ( +
+

+ {anecdote.body} +

+

+ {anecdote.title} +

+
+ ))} + +

+ {sectorValidation.body} +

+
+
+ )} +
+ + + {(surveyEvidence) => ( +
+

+ {surveyEvidence.title} +

+
+ {surveyEvidence.body} +
+
+ )} +
+ + {(whoGrantees || whoFunders) && ( +
+
+

+ Who is this for? +

+
+ {[whoGrantees, whoFunders].map((section, index) => ( + + {(section) => ( +
+

+ {section.title} +

+
+ {section.items?.map((item, itemIndex) => ( +
+

+ {item.title} +

+

{item.body}

+
+ ))} +
+
+ )} +
+ ))} +
+
+
+ )} + + + {(roadmap) => ( +
+

+ {roadmap.title} +

+
+ {roadmap.body} +
+
+ )} +
+
+ + ); +} diff --git a/frontend-typescript/src/pages/site/Security.test.tsx b/frontend-typescript/src/pages/site/Security.test.tsx new file mode 100644 index 00000000..3a1bcdc2 --- /dev/null +++ b/frontend-typescript/src/pages/site/Security.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import SecurityPage from "./Security"; + +function renderPage() { + return render( + + + , + ); +} + +describe("SecurityPage", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("renders the published sections", () => { + renderPage(); + + expect( + screen.getByText("Handled with the care we'd want for our own data"), + ).toBeInTheDocument(); + expect(screen.getByText("Self-hosting")).toBeInTheDocument(); + expect(screen.getByText("AI providers and data")).toBeInTheDocument(); + expect( + screen.getByText("Talk to us about your security needs"), + ).toBeInTheDocument(); + }); + + it("shows the draft GDPR and data-location sections outside production", () => { + vi.stubEnv("PROD", false); + + renderPage(); + + expect(screen.getByText("GDPR compliance")).toBeInTheDocument(); + expect(screen.getByText("Where your data is stored")).toBeInTheDocument(); + expect(screen.getAllByText("TBC").length).toBeGreaterThanOrEqual(2); + }); + + it("hides the draft GDPR and data-location sections in production", () => { + vi.stubEnv("PROD", true); + + renderPage(); + + expect(screen.queryByText("GDPR compliance")).not.toBeInTheDocument(); + expect(screen.queryByText("Where your data is stored")).not.toBeInTheDocument(); + expect(screen.queryByText("TBC")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-typescript/src/pages/site/Security.tsx b/frontend-typescript/src/pages/site/Security.tsx new file mode 100644 index 00000000..dc333dbe --- /dev/null +++ b/frontend-typescript/src/pages/site/Security.tsx @@ -0,0 +1,59 @@ +import { getSection, type SiteSection } from "@/lib/siteContent"; +import { SiteMarkdown } from "@/components/site/SiteMarkdown"; +import { SectionVisibility } from "@/components/site/SectionVisibility"; +import { brand } from "@/lib/brand"; + +function ProseSection({ section }: { section: SiteSection | null }) { + return ( + + {(section) => ( +
+

+ {section.title} +

+
+ {section.body} +
+
+ )} +
+ ); +} + +export default function SecurityPage() { + const intro = getSection("security.intro"); + const selfHosting = getSection("security.self-hosting"); + const aiProviders = getSection("security.ai-providers"); + const gdpr = getSection("security.gdpr"); + const dataLocation = getSection("security.data-location"); + const highRisk = getSection("security.high-risk"); + + return ( + <> + Security & data · Open Grant Flow +
+ + {(intro) => ( +
+

+ {intro.title} +

+
+ {intro.body} +
+
+ )} +
+ + + + + + +
+ + ); +} diff --git a/frontend-typescript/src/pages/site/publicRoutes.test.tsx b/frontend-typescript/src/pages/site/publicRoutes.test.tsx new file mode 100644 index 00000000..9a612ce3 --- /dev/null +++ b/frontend-typescript/src/pages/site/publicRoutes.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "@/App"; + +function makeFakeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" })); + const body = btoa(JSON.stringify(payload)); + return `${header}.${body}.signature`; +} + +function renderAppAt(path: string) { + window.history.pushState({}, "", path); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +function signInAsVerifiedUser() { + localStorage.setItem( + "token", + makeFakeJwt({ user_id: "u1", email_verified: true }), + ); + localStorage.setItem("username", "jane@example.com"); +} + +describe("public site routing", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it.each([false, true])( + "keeps About, Contact and demo navigation public (authenticated: %s)", + async (authenticated) => { + if (authenticated) signInAsVerifiedUser(); + renderAppAt("/security"); + + await userEvent.click(screen.getAllByRole("link", { name: "About" })[0]); + expect(window.location.pathname).toBe("/about"); + expect(screen.getByRole("heading", { name: "Built from lived experience" })).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole("link", { name: "Contact" })[0]); + expect(window.location.pathname).toBe("/contact"); + expect(screen.getByRole("heading", { name: "Start a conversation" })).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole("link", { name: "Security" })[0]); + await userEvent.click(screen.getByRole("link", { name: "Request Demo" })); + expect(window.location.pathname).toBe("/contact"); + expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole("link", { name: "Security" })[0]); + await userEvent.click(screen.getByRole("link", { name: "request a demo" })); + expect(window.location.pathname).toBe("/contact"); + expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); + }, + ); + + it.each(["/", "/legal"])( + "renders %s inside the shared public header and footer", + async (path) => { + renderAppAt(path); + + expect( + await screen.findByRole("navigation", { name: "Primary" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("banner")).toHaveLength(1); + expect(screen.getAllByRole("contentinfo")).toHaveLength(1); + }, + ); + + it("renders How it works directly for an anonymous visitor, with its title", async () => { + renderAppAt("/how-it-works"); + + await waitFor(() => { + expect( + screen.getByText("One continuous financial record, from budget to report"), + ).toBeInTheDocument(); + }); + expect(document.title).toBe("How it works · Open Grant Flow"); + }); + + it("renders How it works for an authenticated visitor without redirecting", async () => { + signInAsVerifiedUser(); + + renderAppAt("/how-it-works"); + + await waitFor(() => { + expect( + screen.getByText("One continuous financial record, from budget to report"), + ).toBeInTheDocument(); + }); + }); + + it("renders Security & data directly for an anonymous visitor, with its title", async () => { + renderAppAt("/security"); + + await waitFor(() => { + expect( + screen.getByText("Handled with the care we'd want for our own data"), + ).toBeInTheDocument(); + }); + expect(document.title).toBe("Security & data · Open Grant Flow"); + }); + + it("renders Security & data for an authenticated visitor without redirecting", async () => { + signInAsVerifiedUser(); + + renderAppAt("/security"); + + await waitFor(() => { + expect( + screen.getByText("Handled with the care we'd want for our own data"), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx new file mode 100644 index 00000000..df00e47a --- /dev/null +++ b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { buildSiteContent, getSection } from "@/lib/siteContent"; +import HowItWorksPage from "./HowItWorks"; +import SecurityPage from "./Security"; + +vi.mock("@/lib/siteContent", async (importOriginal) => ({ + ...await importOriginal(), + getSection: vi.fn(), +})); + +const files = import.meta.glob( + ["../../content/site/**/*.md", "!../../content/site/README.md"], + { eager: true, query: "?raw", import: "default" }, +) as Record; +const sections = [...buildSiteContent(files).sections.values()]; + +describe.each(sections)("$id publication lifecycle", (section) => { + it.each([false, true])("handles draft content (production: %s)", (isProd) => { + const changed = Object.fromEntries(Object.entries(files).map(([path, raw]) => [ + path, + raw.includes(`id: ${section.id}\n`) + ? raw.replace(/status: (published|draft)/, "status: draft") + : raw.replace(/status: (published|draft)/, "status: published"), + ])); + vi.mocked(getSection).mockImplementation(buildSiteContent(changed, { isProd }).getSection); + const Page = section.page === "security" ? SecurityPage : HowItWorksPage; + render(); + + if (isProd) { + expect(document.getElementById(section.anchor)).not.toBeInTheDocument(); + expect(screen.queryByText("TBC")).not.toBeInTheDocument(); + } else { + expect(document.getElementById(section.anchor)).toBeInTheDocument(); + expect(screen.getByText("TBC")).toBeInTheDocument(); + } + }); +}); + +it.each([false, true])("removes TBC after publishing security sections (production: %s)", (isProd) => { + const published = Object.fromEntries(Object.entries(files).map(([path, raw]) => [ + path, raw.replace("status: draft", "status: published"), + ])); + vi.mocked(getSection).mockImplementation(buildSiteContent(published, { isProd }).getSection); + render(); + expect(screen.getByText("GDPR compliance")).toBeInTheDocument(); + expect(screen.getByText("Where your data is stored")).toBeInTheDocument(); + expect(screen.queryByText("TBC")).not.toBeInTheDocument(); +}); + +it("renders every sector-validation anecdote, not just the first", () => { + const withSecondAnecdote = Object.fromEntries(Object.entries(files).map(([path, raw]) => [ + path, + raw.includes("id: how-it-works.sector-validation\n") + ? raw.replace("items:\n", "items:\n - title: Second source\n body: Second anecdote body.\n") + : raw, + ])); + vi.mocked(getSection).mockImplementation(buildSiteContent(withSecondAnecdote).getSection); + render(); + expect(screen.getByText("Second anecdote body.")).toBeInTheDocument(); + expect(screen.getByText(/One leader of a small local nonprofit/)).toBeInTheDocument(); +}); diff --git a/openspec/changes/frontend-feat-372-public-site-content/design.md b/openspec/changes/frontend-feat-372-public-site-content/design.md index fb87cf94..57891a3c 100644 --- a/openspec/changes/frontend-feat-372-public-site-content/design.md +++ b/openspec/changes/frontend-feat-372-public-site-content/design.md @@ -22,7 +22,6 @@ - A CMS, or content editing outside git. - Server-side rendering or prerendering for SEO. Worth revisiting later; it does not affect the content contract. - The assistant UI or any AI behaviour. -- Moving `/legal` copy into content files. It has its own spec (`privacy-policy`) and is not in the assistant's planned corpus. ## Decisions @@ -64,12 +63,16 @@ A layout route wraps the five pages and renders the header (with a mobile menu t ### D9. Legacy anchor redirects on Home Existing inbound links such as `/#contact` and `/#about` would otherwise land at the top of Home. Home maps the known legacy hashes to their new pages: `#contact`→`/contact`, `#about` and `#vision`→`/about`, `#platform` and `#problem`→`/how-it-works`, `#founding-partners`→`/contact#pilot`. +### D10. `/legal` follows the same one-file-per-section contract, split at subsection granularity +`page: legal` content files use the same D2 rule: one file per subsection, not one file for all of Privacy or all of Terms — e.g. `legal.privacy-legal-status`, `legal.privacy-retention`, `legal.privacy-processors`, `legal.privacy-contact`, `legal.terms-service`, `legal.terms-no-warranty`. This gives the assistant a citable ID per compliance fact instead of one undifferentiated page, which is the reason to migrate `/legal` at all. `Legal.tsx`'s `#privacy` and `#terms` anchors stay as the two on-page groupings; the route itself is unaffected. + ## Risks / Trade-offs - [Rewriting copy loses the current spec'd content, such as the anecdote] → The `landing-page` delta keeps the sector-validation requirement, including the anecdote, now placed on How it works. The page tests assert it. - [Authors break an ID or anchor that later changes cite] → The spec treats that as a breaking content change. Once the retrieval change lands, its golden dataset will fail on a dangling ID, so the loss is caught by CI, not by visitors. - [Draft text is visible in the bundle] → Accepted (D6). The repo is public. - [No prerendering hurts SEO for five new URLs] → Same as today's single page. Deferred. +- [Migrating compliance-sensitive legal copy risks wording drift] → Legal content files copy `Legal.tsx`'s existing text verbatim, no rewrite. A render test asserts the migrated sections still satisfy the `privacy-policy` spec's exact-wording scenarios (retention posture, subprocessor list, contact address, legal-status statement). ## Migration Plan diff --git a/openspec/changes/frontend-feat-372-public-site-content/proposal.md b/openspec/changes/frontend-feat-372-public-site-content/proposal.md index 297b5fac..82472e6e 100644 --- a/openspec/changes/frontend-feat-372-public-site-content/proposal.md +++ b/openspec/changes/frontend-feat-372-public-site-content/proposal.md @@ -4,7 +4,8 @@ The public site is one 861-line `LandingPage.tsx`, and all its copy is hard-code ## What Changes -- Split the landing page into five public routes: `/`, `/how-it-works`, `/security`, `/about`, `/contact`. They share a header (with a mobile menu), a footer and per-page ``s. `/legal` is unchanged. +- Split the landing page into five public routes: `/`, `/how-it-works`, `/security`, `/about`, `/contact`. They share a header (with a mobile menu), a footer and per-page `<title>`s. +- `/legal`'s copy moves into content files too, under the same one-file-per-section contract, so it's part of the assistant's retrievable corpus. Its route and in-page anchors (`#privacy`, `#terms`) don't change — only where the copy lives. - Move all public-page copy into markdown files with YAML frontmatter under `frontend-typescript/src/content/site/`, **one file per section**. Frontmatter carries a stable `id`, `page`, `anchor`, `title`, optional `personas`, a `status` (`published` | `draft`) and optional structured `items` (cards, steps, quotes, FAQs). - Page components own the layout. Copy comes from the content loader by section ID, so a copy edit never touches TSX. - `draft` sections render only in non-production builds, with a visible "TBC" marker. They are never rendered in production. This is how the redesign's unresolved TBC items ship safely. @@ -24,7 +25,7 @@ The public site is one 861-line `LandingPage.tsx`, and all its copy is hard-code ## Impact -- **Frontend:** `LandingPage.tsx` is replaced by `src/pages/site/*`, `src/content/site/**` and `src/lib/siteContent.ts`. `App.tsx` gains public routes, registered before the `*` → `/dashboard` catch-all. `LandingPage.test.tsx` is replaced by per-page tests and the content validation test. +- **Frontend:** `LandingPage.tsx` is replaced by `src/pages/site/*`, `src/content/site/**` and `src/lib/siteContent.ts`. `App.tsx` gains public routes, registered before the `*` → `/dashboard` catch-all. `LandingPage.test.tsx` is replaced by per-page tests and the content validation test. `Legal.tsx`/`Legal.test.tsx` are replaced the same way, on the same content loader. - **New npm dependencies:** `react-markdown` (renders section bodies with no raw-HTML passthrough) and `yaml` (a browser-safe frontmatter parser). `gray-matter` is avoided because it needs Node `Buffer` polyfills under Vite. - **Hosting:** Vercel (`vercel.json`) already rewrites every path to `index.html`, and so does `nginx.frontend.conf` (`try_files`), so no gateway change is needed. - **Downstream:** `ai-feat-site-assistant-retrieval-evals` depends on this change and reads the same content files. diff --git a/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md b/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md index 544cc2b6..043b314a 100644 --- a/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md +++ b/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md @@ -7,7 +7,7 @@ Defines how public-site copy is authored: as versioned, individually addressable ## ADDED Requirements ### Requirement: Public-site copy is stored as one content file per section -All copy on the public pages (Home, How it works, Security & data, About, Contact) SHALL be stored in repository content files, one file per page section. It SHALL NOT be embedded in page component source. Each file SHALL consist of YAML frontmatter followed by an optional markdown body. +All copy on the public pages (Home, How it works, Security & data, About, Contact, Legal) SHALL be stored in repository content files, one file per page section. It SHALL NOT be embedded in page component source. Each file SHALL consist of YAML frontmatter followed by an optional markdown body. #### Scenario: Copy edit without code change - **WHEN** an author changes the wording of a public-page section @@ -21,7 +21,7 @@ All copy on the public pages (Home, How it works, Security & data, About, Contac ### Requirement: Section frontmatter carries provenance metadata Every section file's frontmatter SHALL include: - `id`: a globally unique, stable identifier in the form `<page>.<slug>` -- `page`: one of `home`, `how-it-works`, `security`, `about`, `contact` +- `page`: one of `home`, `how-it-works`, `security`, `about`, `contact`, `legal` - `anchor`: a URL fragment, unique within its page - `title`: the section heading as shown to visitors - `status`: `published` or `draft` diff --git a/openspec/changes/frontend-feat-372-public-site-content/tasks.md b/openspec/changes/frontend-feat-372-public-site-content/tasks.md index 53ef6ce5..9d202368 100644 --- a/openspec/changes/frontend-feat-372-public-site-content/tasks.md +++ b/openspec/changes/frontend-feat-372-public-site-content/tasks.md @@ -18,15 +18,15 @@ _Generated by `scripts/flow.py sync frontend-feat-372-public-site-content` — d ## 1. Content contract, loader and first content-driven pages (How it works, Security & data) — Issue #373 -- [ ] 1.0 Run `scripts/flow.py start frontend-feat-public-site-content 1` to move this group to In Progress and create its branch before starting any other work in this group. -- [ ] 1.1 Add `react-markdown` and `yaml` to `frontend-typescript/package.json`; verify `npm ci` and `npx vitest run` still pass. -- [ ] 1.2 Implement `src/lib/siteContent.ts` (D4, D6): eager `?raw` glob of `src/content/site/**/*.md`, frontmatter split, contract validation, `getSection(id)` throwing on unknown IDs, and draft filtering by `import.meta.env.PROD`. Verify with `siteContent.test.ts` covering: missing field, bad `page`/`status`/`personas` value, duplicate `id`, duplicate anchor on the same page, draft hidden when PROD is stubbed true and shown when false. -- [ ] 1.3 Add a `SiteMarkdown` component (D7) and a `TbcMarker` wrapper. Verify with tests that raw `<script>`/HTML in a body produces no element, internal links render as router links, and external links get `rel="noopener noreferrer"`. -- [ ] 1.4 Add `PublicLayout` (D8): shared header with the mobile menu toggle, footer (spec links including `/legal#privacy` and `/legal#terms`), and hash scrolling. Verify with tests that the mobile toggle exposes all nav links and that `/how-it-works#five-steps` scrolls to that section (`scrollIntoView` spy). -- [ ] 1.5 Author the How it works content files from the redesign: intro, five steps, survey evidence, roadmap, "who it's for" (grantee and funder items), and the sector-validation block (≥3 role-attributed quotes, the existing paraphrased anecdote carried over from `LandingPage.tsx`, the closing statement). Build `src/pages/site/HowItWorks.tsx` on it. Verify with a render test covering the `landing-page` sector-validation scenarios. -- [ ] 1.6 Author the Security & data content files, with TBC items (GDPR wording, data location) as `status: draft`, and build `src/pages/site/Security.tsx`. Verify with a render test that draft sections are absent under a PROD stub. -- [ ] 1.7 Register `/how-it-works` and `/security` under `PublicLayout` in `App.tsx`, before the `*` catch-all, with per-page `<title>`s. Verify with a routing test that a direct visit renders each page for both an anonymous and an authenticated user, and that each title is set. -- [ ] 1.8 Add a short author guide at `src/content/site/README.md` (fields, ID/anchor stability rule, draft lifecycle). Verify it matches the `site-content` spec fields. +- [x] 1.0 Run `scripts/flow.py start frontend-feat-public-site-content 1` to move this group to In Progress and create its branch before starting any other work in this group. +- [x] 1.1 Add `react-markdown` and `yaml` to `frontend-typescript/package.json`; verify `npm ci` and `npx vitest run` still pass. +- [x] 1.2 Implement `src/lib/siteContent.ts` (D4, D6): eager `?raw` glob of `src/content/site/**/*.md`, frontmatter split, contract validation, `getSection(id)` throwing on unknown IDs, and draft filtering by `import.meta.env.PROD`. Verify with `siteContent.test.ts` covering: missing field, bad `page`/`status`/`personas` value, duplicate `id`, duplicate anchor on the same page, draft hidden when PROD is stubbed true and shown when false. +- [x] 1.3 Add a `SiteMarkdown` component (D7) and a `TbcMarker` wrapper. Verify with tests that raw `<script>`/HTML in a body produces no element, internal links render as router links, and external links get `rel="noopener noreferrer"`. +- [x] 1.4 Add `PublicLayout` (D8): shared header with the mobile menu toggle, footer (spec links including `/legal#privacy` and `/legal#terms`), and hash scrolling. Verify with tests that the mobile toggle exposes all nav links and that `/how-it-works#five-steps` scrolls to that section (`scrollIntoView` spy). +- [x] 1.5 Author the How it works content files from the redesign: intro, five steps, survey evidence, roadmap, "who it's for" (grantee and funder items), and the sector-validation block (≥3 role-attributed quotes, the existing paraphrased anecdote carried over from `LandingPage.tsx`, the closing statement). Build `src/pages/site/HowItWorks.tsx` on it. Verify with a render test covering the `landing-page` sector-validation scenarios. +- [x] 1.6 Author the Security & data content files, with TBC items (GDPR wording, data location) as `status: draft`, and build `src/pages/site/Security.tsx`. Verify with a render test that draft sections are absent under a PROD stub. +- [x] 1.7 Register `/how-it-works` and `/security` under `PublicLayout` in `App.tsx`, before the `*` catch-all, with per-page `<title>`s. Verify with a routing test that a direct visit renders each page for both an anonymous and an authenticated user, and that each title is set. +- [x] 1.8 Add a short author guide at `src/content/site/README.md` (fields, ID/anchor stability rule, draft lifecycle). Verify it matches the `site-content` spec fields. - [ ] 1.9 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. ## 2. About and Contact pages — ticket depends on 1 — Issue #374 @@ -46,3 +46,11 @@ _Generated by `scripts/flow.py sync frontend-feat-372-public-site-content` — d - [ ] 3.3 Delete `LandingPage.tsx` and `LandingPage.test.tsx`, and move every assertion still relevant into the new page tests. Verify with `grep -rn "LandingPage" frontend-typescript/src` returning nothing, and confirm no copy string remains hard-coded in `src/pages/site/*.tsx` (only section IDs). - [ ] 3.4 Run a production build check (`npx vite build`, then `npx vite preview`). Manually verify all five routes and a hard reload on `/security#…`, confirm no TBC marker or draft copy is visible, and confirm the mobile menu works at phone width. - [ ] 3.5 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. + +## 4. Migrate `/legal` onto content files — ticket depends on 1 + +- [ ] 4.0 Run `scripts/flow.py start frontend-feat-public-site-content 4` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 4.1 Author the legal content files under `src/content/site/legal/`, one file per subsection per D10 (`legal.privacy-legal-status`, `legal.privacy-info-collected`, `legal.privacy-how-we-use`, `legal.privacy-self-hosting`, `legal.privacy-retention`, `legal.privacy-processors`, `legal.privacy-contact`, `legal.terms-service`, `legal.terms-no-warranty`, `legal.terms-acceptable-use`, `legal.terms-changes`, `legal.terms-contact`), copying `Legal.tsx`'s existing wording verbatim. +- [ ] 4.2 Build `src/pages/site/Legal.tsx` on the content loader, preserving the `/legal` route and the `#privacy`/`#terms` in-page anchors. Verify with a render test that every migrated section renders and that the retention, subprocessor-list, contact-address and legal-status wording still matches the `privacy-policy` spec's scenarios. +- [ ] 4.3 Register the new `Legal.tsx` in `App.tsx` in place of the old one; delete `Legal.tsx`'s old implementation and `Legal.test.tsx`'s superseded assertions. Verify with `grep -rn "LAST_UPDATED" frontend-typescript/src` returning nothing outside the new content files' frontmatter. +- [ ] 4.4 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 948bdf55..59e9a2a2 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Mirrors .github/workflows/{ai,budget,chat,users,shared,tooling,worker}.yml's lint -# steps locally, scoped to whatever's actually being pushed. See +# Mirrors .github/workflows/{ai,budget,chat,users,shared,tooling,worker,frontend}.yml's +# lint steps locally, scoped to whatever's actually being pushed. See # docs/development/WORKFLOW.md for setup (git config core.hooksPath). set -euo pipefail @@ -28,9 +28,9 @@ while read -r local_ref local_sha remote_ref remote_sha; do fi if [[ -z "$base" ]]; then - changed=$(git diff --name-only --diff-filter=ACM "$local_sha" -- '*.py' || true) + changed=$(git diff --name-only --diff-filter=ACM "$local_sha" -- '*.py' '*.ts' '*.tsx' || true) else - changed=$(git diff --name-only --diff-filter=ACM "$base" "$local_sha" -- '*.py' || true) + changed=$(git diff --name-only --diff-filter=ACM "$base" "$local_sha" -- '*.py' '*.ts' '*.tsx' || true) fi [[ -z "$changed" ]] && continue @@ -44,6 +44,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do services/worker/*) areas["worker"]=1 ;; scripts/*) areas["scripts"]=1 ;; shared/*) areas["ai"]=1; areas["budget"]=1; areas["chat"]=1; areas["users"]=1; areas["shared"]=1 ;; + frontend-typescript/*) areas["frontend"]=1 ;; esac done <<< "$changed" done @@ -91,6 +92,12 @@ for area in "${!areas[@]}"; do run flake8 --max-line-length=100 scripts/ run python3 -m pytest scripts/test_flow.py -q ;; + frontend) + echo "== frontend-typescript ==" + pushd frontend-typescript > /dev/null + run npm run lint:ci + popd > /dev/null + ;; esac done From 3580c2d0e4152b02964c65d6f3fd55f69c84985f Mon Sep 17 00:00:00 2001 From: Norair Arutshyan <n.arutshyan@gmail.com> Date: Thu, 1 Oct 2026 14:41:14 +0100 Subject: [PATCH 2/6] feat(public-site-content): content-driven About and Contact pages (#374) Add About and Contact pages on the site content loader, with unconfirmed claims (CIC status, pilot schedule, time commitment) shipped as drafts. Move the demo-request form into DemoRequestForm with organisation type, reporting-time and pilot-interest fields, still posting to web3forms. Define the brand palette as Tailwind @theme tokens and use brand-* classes in the public site instead of inline styles; brand.ts now references the CSS variables so the hex values live in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend-typescript/src/App.tsx | 9 +- .../src/components/site/DemoRequestForm.tsx | 185 ++++++++++++++++++ .../src/components/site/PublicLayout.tsx | 26 +-- .../src/content/site/about/mission.md | 11 ++ .../src/content/site/about/open-code.md | 18 ++ .../src/content/site/about/origin.md | 12 ++ .../src/content/site/about/set-up.md | 8 + .../src/content/site/about/values.md | 16 ++ .../src/content/site/contact/demo-request.md | 8 + .../src/content/site/contact/faq-time.md | 8 + .../src/content/site/contact/faq.md | 14 ++ .../src/content/site/contact/pilot-details.md | 8 + .../src/content/site/contact/pilot.md | 11 ++ .../src/content/site/contact/what-we-ask.md | 12 ++ .../content/site/contact/what-you-receive.md | 16 ++ frontend-typescript/src/lib/brand.ts | 14 +- frontend-typescript/src/pages/LandingPage.tsx | 7 +- .../src/pages/site/About.test.tsx | 60 ++++++ frontend-typescript/src/pages/site/About.tsx | 110 +++++++++++ .../src/pages/site/Contact.test.tsx | 112 +++++++++++ .../src/pages/site/Contact.tsx | 133 +++++++++++++ .../src/pages/site/HowItWorks.tsx | 54 +++-- .../src/pages/site/Security.tsx | 11 +- .../src/pages/site/publicRoutes.test.tsx | 27 +++ .../src/pages/site/sectionLifecycle.test.tsx | 14 +- frontend-typescript/src/style.css | 12 ++ .../tasks.md | 8 +- 27 files changed, 851 insertions(+), 73 deletions(-) create mode 100644 frontend-typescript/src/components/site/DemoRequestForm.tsx create mode 100644 frontend-typescript/src/content/site/about/mission.md create mode 100644 frontend-typescript/src/content/site/about/open-code.md create mode 100644 frontend-typescript/src/content/site/about/origin.md create mode 100644 frontend-typescript/src/content/site/about/set-up.md create mode 100644 frontend-typescript/src/content/site/about/values.md create mode 100644 frontend-typescript/src/content/site/contact/demo-request.md create mode 100644 frontend-typescript/src/content/site/contact/faq-time.md create mode 100644 frontend-typescript/src/content/site/contact/faq.md create mode 100644 frontend-typescript/src/content/site/contact/pilot-details.md create mode 100644 frontend-typescript/src/content/site/contact/pilot.md create mode 100644 frontend-typescript/src/content/site/contact/what-we-ask.md create mode 100644 frontend-typescript/src/content/site/contact/what-you-receive.md create mode 100644 frontend-typescript/src/pages/site/About.test.tsx create mode 100644 frontend-typescript/src/pages/site/About.tsx create mode 100644 frontend-typescript/src/pages/site/Contact.test.tsx create mode 100644 frontend-typescript/src/pages/site/Contact.tsx diff --git a/frontend-typescript/src/App.tsx b/frontend-typescript/src/App.tsx index 35d6b764..a19e2b11 100644 --- a/frontend-typescript/src/App.tsx +++ b/frontend-typescript/src/App.tsx @@ -2,11 +2,13 @@ import { Routes, Route, Navigate, BrowserRouter, Outlet } from "react-router-dom import { useAuth, AuthProvider } from "./context/AuthContext"; import { AiChatProvider } from "./context/AiChatContext"; import Login from "./pages/Login"; -import LandingPage, { FounderSection, ContactSection } from "./pages/LandingPage"; +import LandingPage from "./pages/LandingPage"; import LegalPage from "./pages/Legal"; import PublicLayout from "./components/site/PublicLayout"; import HowItWorksPage from "./pages/site/HowItWorks"; import SecurityPage from "./pages/site/Security"; +import AboutPage from "./pages/site/About"; +import ContactPage from "./pages/site/Contact"; import Dashboard from "./pages/Dashboard/Dashboard"; import { JSX } from "react"; import Register from "./pages/Register"; @@ -52,9 +54,8 @@ export default function App() { <Route path="/legal" element={<LegalPage />} /> <Route path="/how-it-works" element={<HowItWorksPage />} /> <Route path="/security" element={<SecurityPage />} /> - {/* Reuse existing content until the dedicated pages are ready. */} - <Route path="/about" element={<FounderSection />} /> - <Route path="/contact" element={<ContactSection />} /> + <Route path="/about" element={<AboutPage />} /> + <Route path="/contact" element={<ContactPage />} /> </Route> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> diff --git a/frontend-typescript/src/components/site/DemoRequestForm.tsx b/frontend-typescript/src/components/site/DemoRequestForm.tsx new file mode 100644 index 00000000..b462be2c --- /dev/null +++ b/frontend-typescript/src/components/site/DemoRequestForm.tsx @@ -0,0 +1,185 @@ +import { useState, type FormEvent } from "react"; +import { Link } from "react-router-dom"; + +const ORGANISATION_TYPES = [ + "Nonprofit or NGO", + "Foundation", + "Institutional donor", + "Other", +]; + +const inputClass = + "w-full px-4 py-2 border border-slate-300 rounded-lg bg-white input-focus aria-[invalid=true]:border-red-500"; + +function FieldLabel({ htmlFor, children }: { htmlFor: string; children: string }) { + return ( + <label + htmlFor={htmlFor} + className="block text-sm font-medium mb-2 text-brand-slate" + > + {children} + </label> + ); +} + +export function DemoRequestForm() { + const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(false); + const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set()); + + const accessKey = import.meta.env.VITE_WEB3FORMS_ACCESS_KEY; + + function flagInvalid(name: string) { + setInvalidFields((fields) => new Set(fields).add(name)); + } + + function clearInvalid(name: string) { + setInvalidFields((fields) => { + if (!fields.has(name)) return fields; + const next = new Set(fields); + next.delete(name); + return next; + }); + } + + const fieldProps = (name: string) => ({ + name, + "aria-invalid": invalidFields.has(name), + onInvalid: () => flagInvalid(name), + onChange: () => clearInvalid(name), + className: inputClass, + }); + + async function handleSubmit(e: FormEvent<HTMLFormElement>) { + e.preventDefault(); + setSubmitting(true); + setError(false); + + const formData = new FormData(e.currentTarget); + + try { + const res = await fetch("https://api.web3forms.com/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + access_key: accessKey, + subject: "New Open Grant Flow demo request", + name: formData.get("name"), + email: formData.get("email"), + organisation: formData.get("organisation"), + organisation_type: formData.get("organisation_type"), + reporting_time: formData.get("reporting_time"), + pilot_interest: formData.get("pilot_interest") === "on", + }), + }); + if (!res.ok) throw new Error("submission failed"); + setSubmitted(true); + } catch { + setError(true); + } finally { + setSubmitting(false); + } + } + + if (submitted) { + return ( + <div className="bg-white rounded-2xl card-shadow-lg p-8 text-center"> + <p className="text-brand-slate"> + Thank you — we've received your request and will be in touch. + </p> + </div> + ); + } + + return ( + <form + onSubmit={handleSubmit} + className="bg-white rounded-2xl card-shadow-lg p-8 space-y-5" + > + <div> + <FieldLabel htmlFor="demo-name">Name</FieldLabel> + <input id="demo-name" type="text" required {...fieldProps("name")} /> + </div> + <div> + <FieldLabel htmlFor="demo-email">Work email</FieldLabel> + <input id="demo-email" type="email" required {...fieldProps("email")} /> + </div> + <div> + <FieldLabel htmlFor="demo-organisation">Organisation</FieldLabel> + <input + id="demo-organisation" + type="text" + required + {...fieldProps("organisation")} + /> + </div> + <div> + <FieldLabel htmlFor="demo-organisation-type">Organisation type</FieldLabel> + <select + id="demo-organisation-type" + required + defaultValue="" + {...fieldProps("organisation_type")} + > + <option value="" disabled> + Select an organisation type + </option> + {ORGANISATION_TYPES.map((type) => ( + <option key={type} value={type}> + {type} + </option> + ))} + </select> + </div> + <div> + <FieldLabel htmlFor="demo-reporting-time"> + How long does a donor report take your team today? (optional) + </FieldLabel> + <textarea + id="demo-reporting-time" + name="reporting_time" + rows={3} + className={inputClass} + /> + </div> + <div className="flex items-center gap-2"> + <input + id="demo-pilot-interest" + name="pilot_interest" + type="checkbox" + className="h-4 w-4 rounded border-slate-300" + /> + <label + htmlFor="demo-pilot-interest" + className="text-sm text-brand-slate" + > + I'm interested in becoming a Pilot Partner + </label> + </div> + + <p className="text-xs text-slate-500"> + We use these details only to reply to your request. See our{" "} + <Link to="/legal#privacy" className="underline hover:opacity-70"> + privacy policy + </Link> + . + </p> + + {error && ( + <p className="text-sm text-red-600"> + Something went wrong sending your request. Please try again, or email + us directly. + </p> + )} + + <button + type="submit" + disabled={submitting} + className="w-full rounded-lg px-6 py-3 font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50 bg-brand-navy" + > + {submitting ? "Sending…" : "Request a demo"} + </button> + </form> + ); +} diff --git a/frontend-typescript/src/components/site/PublicLayout.tsx b/frontend-typescript/src/components/site/PublicLayout.tsx index 3527a955..6c6d9435 100644 --- a/frontend-typescript/src/components/site/PublicLayout.tsx +++ b/frontend-typescript/src/components/site/PublicLayout.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { Link, Outlet, useLocation } from "react-router-dom"; import { Menu, X, Github, Linkedin } from "lucide-react"; import ogfIcon from "@/assets/logos/ogf-icon.svg"; -import { brand } from "@/lib/brand"; const NAV_LINKS = [ { to: "/how-it-works", label: "How it works" }, @@ -33,8 +32,7 @@ function RequestDemoButton({ className = "" }: { className?: string }) { return ( <Link to="/contact" - className={`rounded-lg px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white transition-opacity hover:opacity-90 whitespace-nowrap ${className}`} - style={{ backgroundColor: brand.navy }} + className={`rounded-lg px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white transition-opacity hover:opacity-90 whitespace-nowrap bg-brand-navy ${className}`} > Request Demo </Link> @@ -50,10 +48,9 @@ function PublicHeader() { <Link to="/" className="flex items-center gap-2"> <img src={ogfIcon} alt="" className="h-8 w-auto shrink-0" /> <span - className="text-lg sm:text-xl font-bold whitespace-nowrap" - style={{ color: brand.slate }} + className="text-lg sm:text-xl font-bold whitespace-nowrap text-brand-slate" > - Open Grant <span style={{ color: brand.teal }}>Flow</span> + Open Grant <span className="text-brand-teal">Flow</span> </span> </Link> <div className="flex items-center gap-3 sm:gap-6"> @@ -62,8 +59,7 @@ function PublicHeader() { <Link key={to} to={to} - className="text-sm font-medium hover:opacity-70" - style={{ color: brand.slate }} + className="text-sm font-medium hover:opacity-70 text-brand-slate" > {label} </Link> @@ -78,9 +74,9 @@ function PublicHeader() { onClick={() => setMobileOpen((open) => !open)} > {mobileOpen ? ( - <X size={22} style={{ color: brand.slate }} /> + <X size={22} className="text-brand-slate" /> ) : ( - <Menu size={22} style={{ color: brand.slate }} /> + <Menu size={22} className="text-brand-slate" /> )} </button> </div> @@ -94,8 +90,7 @@ function PublicHeader() { <Link key={to} to={to} - className="py-2 text-sm font-medium" - style={{ color: brand.slate }} + className="py-2 text-sm font-medium text-brand-slate" onClick={() => setMobileOpen(false)} > {label} @@ -113,7 +108,7 @@ function PublicFooter() { <footer className="border-t border-slate-200 px-6 py-10"> <div className="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between gap-6"> <div> - <p className="font-bold" style={{ color: brand.slate }}> + <p className="font-bold text-brand-slate"> Open Grant Flow </p> <p className="text-sm text-slate-500"> @@ -122,8 +117,7 @@ function PublicFooter() { </div> <div className="flex flex-col sm:items-end gap-3"> <nav - className="flex flex-wrap gap-4 text-sm" - style={{ color: brand.slate }} + className="flex flex-wrap gap-4 text-sm text-brand-slate" > {FOOTER_LINKS.map(({ to, label }) => ( <Link key={to} to={to} className="hover:opacity-70"> @@ -168,7 +162,7 @@ export default function PublicLayout() { useHashScroll(); return ( - <div className="min-h-screen" style={{ backgroundColor: brand.offWhite }}> + <div className="min-h-screen bg-brand-off-white"> <PublicHeader /> <Outlet /> <PublicFooter /> diff --git a/frontend-typescript/src/content/site/about/mission.md b/frontend-typescript/src/content/site/about/mission.md new file mode 100644 index 00000000..37c1b1a0 --- /dev/null +++ b/frontend-typescript/src/content/site/about/mission.md @@ -0,0 +1,11 @@ +--- +id: about.mission +page: about +anchor: mission +title: Grant management should be collaborative, not administrative +status: published +personas: [grantee, funder] +--- +Instead of exchanging spreadsheets and emails, donors and nonprofits should share one transparent financial record. + +Open Grant Flow is building open financial infrastructure for grant-funded organisations, so their time goes into delivering impact rather than preparing financial reports. diff --git a/frontend-typescript/src/content/site/about/open-code.md b/frontend-typescript/src/content/site/about/open-code.md new file mode 100644 index 00000000..c21591bb --- /dev/null +++ b/frontend-typescript/src/content/site/about/open-code.md @@ -0,0 +1,18 @@ +--- +id: about.open-code +page: about +anchor: open-code +title: Open by design +status: published +personas: [technical] +items: + - title: Use the hosted platform + body: Get started without running any infrastructure yourself. + - title: Self-host + body: Run Open Grant Flow on your own servers. + - title: Inspect the source code + body: See exactly how your data is handled. + - title: Contribute + body: Developers, designers and nonprofit-sector experts are welcome. +--- +Open Grant Flow is open source. The code is public on [GitHub](https://github.com/arutsh/OpenGrantFlow). diff --git a/frontend-typescript/src/content/site/about/origin.md b/frontend-typescript/src/content/site/about/origin.md new file mode 100644 index 00000000..0062bf63 --- /dev/null +++ b/frontend-typescript/src/content/site/about/origin.md @@ -0,0 +1,12 @@ +--- +id: about.origin +page: about +anchor: origin +title: Built from lived experience +status: published +--- +Open Grant Flow is founded by a software engineer who previously spent 17 years co-founding and leading nonprofit organisations in Armenia. + +During that time, hundreds of grant budgets, donor reports and financial reconciliations revealed the same recurring problem: **too much time is spent managing administration instead of delivering impact.** + +*Open Grant Flow is the platform I wish had existed.* diff --git a/frontend-typescript/src/content/site/about/set-up.md b/frontend-typescript/src/content/site/about/set-up.md new file mode 100644 index 00000000..b0adffcf --- /dev/null +++ b/frontend-typescript/src/content/site/about/set-up.md @@ -0,0 +1,8 @@ +--- +id: about.set-up +page: about +anchor: set-up +title: How we are set up +status: draft +--- +Open Grant Flow is being set up as a Community Interest Company (CIC), so that the platform serves the sector it was built for. Registration details will be published here once confirmed. diff --git a/frontend-typescript/src/content/site/about/values.md b/frontend-typescript/src/content/site/about/values.md new file mode 100644 index 00000000..956be34d --- /dev/null +++ b/frontend-typescript/src/content/site/about/values.md @@ -0,0 +1,16 @@ +--- +id: about.values +page: about +anchor: values +title: What we value +status: published +items: + - title: Transparency + body: Financial infrastructure for the nonprofit sector should be transparent and accessible. Transparency builds trust. + - title: Collaboration + body: Donors and grantees work from one shared record instead of passing files back and forth. + - title: Time for impact + body: Every hour saved on reporting is an hour returned to the work the grant was for. + - title: Built with the sector + body: Pilot Partners shape what we build, so the product fits how grant-funded organisations really work. +--- diff --git a/frontend-typescript/src/content/site/contact/demo-request.md b/frontend-typescript/src/content/site/contact/demo-request.md new file mode 100644 index 00000000..187cd414 --- /dev/null +++ b/frontend-typescript/src/content/site/contact/demo-request.md @@ -0,0 +1,8 @@ +--- +id: contact.demo-request +page: contact +anchor: demo-request +title: Start a conversation +status: published +--- +Tell us a little about your organisation and we'll set up a short demo built around how you manage grants today. diff --git a/frontend-typescript/src/content/site/contact/faq-time.md b/frontend-typescript/src/content/site/contact/faq-time.md new file mode 100644 index 00000000..bde79632 --- /dev/null +++ b/frontend-typescript/src/content/site/contact/faq-time.md @@ -0,0 +1,8 @@ +--- +id: contact.faq-time +page: contact +anchor: faq-time +title: How much time does a pilot take? +status: draft +--- +We aim to keep the time commitment light. Expected hours per month will be confirmed with each partner. diff --git a/frontend-typescript/src/content/site/contact/faq.md b/frontend-typescript/src/content/site/contact/faq.md new file mode 100644 index 00000000..68d26a3b --- /dev/null +++ b/frontend-typescript/src/content/site/contact/faq.md @@ -0,0 +1,14 @@ +--- +id: contact.faq +page: contact +anchor: faq +title: Frequently asked questions +status: published +items: + - title: Is Open Grant Flow free? + body: The code is open source, so you can self-host it for free. We also offer a hosted platform. + - title: Do I need to be a Pilot Partner to get a demo? + body: No. Anyone can request a demo; tick the pilot box only if you'd like to be considered for the programme. + - title: Do I have to use the AI features? + body: No. AI assistance is optional, and you can bring your own provider key. +--- diff --git a/frontend-typescript/src/content/site/contact/pilot-details.md b/frontend-typescript/src/content/site/contact/pilot-details.md new file mode 100644 index 00000000..4c6e7df5 --- /dev/null +++ b/frontend-typescript/src/content/site/contact/pilot-details.md @@ -0,0 +1,8 @@ +--- +id: contact.pilot-details +page: contact +anchor: pilot-details +title: How the pilot runs +status: draft +--- +Pilots run alongside a real grant cycle. The pilot length and month-by-month schedule will be confirmed with each partner. diff --git a/frontend-typescript/src/content/site/contact/pilot.md b/frontend-typescript/src/content/site/contact/pilot.md new file mode 100644 index 00000000..86a0b08c --- /dev/null +++ b/frontend-typescript/src/content/site/contact/pilot.md @@ -0,0 +1,11 @@ +--- +id: contact.pilot +page: contact +anchor: pilot +title: Become a Pilot Partner +status: published +personas: [grantee, funder] +--- +We're looking for a small number of institutional donors, grant-making foundations, international NGOs and nonprofit organisations to help shape Open Grant Flow. + +This is not a sales programme. It is a collaborative product development initiative. diff --git a/frontend-typescript/src/content/site/contact/what-we-ask.md b/frontend-typescript/src/content/site/contact/what-we-ask.md new file mode 100644 index 00000000..10b71737 --- /dev/null +++ b/frontend-typescript/src/content/site/contact/what-we-ask.md @@ -0,0 +1,12 @@ +--- +id: contact.what-we-ask +page: contact +anchor: what-we-ask +title: What we ask +status: published +items: + - title: Use it on real work + body: Run at least one real grant's budget and reporting through Open Grant Flow. + - title: Share honest feedback + body: Tell us what works, what doesn't and what is missing. +--- diff --git a/frontend-typescript/src/content/site/contact/what-you-receive.md b/frontend-typescript/src/content/site/contact/what-you-receive.md new file mode 100644 index 00000000..2f0bf4c8 --- /dev/null +++ b/frontend-typescript/src/content/site/contact/what-you-receive.md @@ -0,0 +1,16 @@ +--- +id: contact.what-you-receive +page: contact +anchor: what-you-receive +title: What you receive +status: published +items: + - title: Direct collaboration + body: Work directly with the founder. + - title: Early access + body: Use new features before they are generally available. + - title: Influence + body: Help decide what we build first. + - title: Discounted hosting + body: Discounted hosted services, when available. +--- diff --git a/frontend-typescript/src/lib/brand.ts b/frontend-typescript/src/lib/brand.ts index 6f45c6bb..0ddbf0aa 100644 --- a/frontend-typescript/src/lib/brand.ts +++ b/frontend-typescript/src/lib/brand.ts @@ -1,7 +1,11 @@ +// Inline-style escape hatch; the palette itself is defined in style.css @theme. export const brand = { - navy: "#163A5F", - teal: "#1F8A70", - gold: "#D9A441", - offWhite: "#F8FAFC", - slate: "#243447", + navy: "var(--color-brand-navy)", + teal: "var(--color-brand-teal)", + gold: "var(--color-brand-gold)", + offWhite: "var(--color-brand-off-white)", + slate: "var(--color-brand-slate)", + mist: "var(--color-brand-mist)", + tealTint: "var(--color-brand-teal-tint)", + tealTintBorder: "var(--color-brand-teal-tint-border)", }; diff --git a/frontend-typescript/src/pages/LandingPage.tsx b/frontend-typescript/src/pages/LandingPage.tsx index 9fc9f2f6..1a12a385 100644 --- a/frontend-typescript/src/pages/LandingPage.tsx +++ b/frontend-typescript/src/pages/LandingPage.tsx @@ -385,8 +385,7 @@ function SolutionSection() { className="bg-white p-6 rounded-2xl card-shadow-lg text-left" > <div - className="p-3 rounded-lg inline-block mb-4" - style={{ backgroundColor: `${brand.navy}14` }} + className="p-3 rounded-lg inline-block mb-4 bg-brand-navy/8" > <Icon size={22} style={{ color: brand.navy }} /> </div> @@ -458,7 +457,7 @@ function OpenSourceSection() { ); } -export function FounderSection() { +function FounderSection() { return ( <section id="about" className="max-w-3xl mx-auto px-6 py-16"> <Kicker>About</Kicker> @@ -555,7 +554,7 @@ function PartnerSection() { ); } -export function ContactSection() { +function ContactSection() { const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(false); diff --git a/frontend-typescript/src/pages/site/About.test.tsx b/frontend-typescript/src/pages/site/About.test.tsx new file mode 100644 index 00000000..e484899c --- /dev/null +++ b/frontend-typescript/src/pages/site/About.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import AboutPage from "./About"; + +function renderPage() { + return render( + <MemoryRouter> + <AboutPage /> + </MemoryRouter>, + ); +} + +describe("AboutPage", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("renders the mission, origin story, values and open-code sections", () => { + renderPage(); + + expect( + screen.getByRole("heading", { + name: "Grant management should be collaborative, not administrative", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Built from lived experience" }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "What we value" })).toBeInTheDocument(); + expect(screen.getByText("Transparency")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Open by design" })).toBeInTheDocument(); + }); + + it("links to the public source code with safe external-link attributes", () => { + renderPage(); + + const github = screen.getByRole("link", { name: "GitHub" }); + expect(github).toHaveAttribute("href", "https://github.com/arutsh/OpenGrantFlow"); + expect(github).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("shows the draft CIC set-up section with a TBC marker outside production", () => { + vi.stubEnv("PROD", false); + + renderPage(); + + expect(screen.getByText("How we are set up")).toBeInTheDocument(); + expect(screen.getByText("TBC")).toBeInTheDocument(); + }); + + it("hides the draft CIC set-up section in production", () => { + vi.stubEnv("PROD", true); + + renderPage(); + + expect(screen.queryByText("How we are set up")).not.toBeInTheDocument(); + expect(screen.queryByText(/Community Interest Company/)).not.toBeInTheDocument(); + expect(screen.queryByText("TBC")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-typescript/src/pages/site/About.tsx b/frontend-typescript/src/pages/site/About.tsx new file mode 100644 index 00000000..abfef06c --- /dev/null +++ b/frontend-typescript/src/pages/site/About.tsx @@ -0,0 +1,110 @@ +import { User } from "lucide-react"; +import { getSection, type SiteSection } from "@/lib/siteContent"; +import { SiteMarkdown } from "@/components/site/SiteMarkdown"; +import { SectionVisibility } from "@/components/site/SectionVisibility"; + +function CardGrid({ section }: { section: SiteSection }) { + return ( + <div className="grid sm:grid-cols-2 gap-6"> + {section.items?.map((item, index) => ( + <div key={index} className="bg-white p-6 rounded-2xl card-shadow-lg"> + <h3 className="font-semibold mb-2 text-brand-slate"> + {item.title} + </h3> + <p className="text-sm text-slate-500">{item.body}</p> + </div> + ))} + </div> + ); +} + +export default function AboutPage() { + const mission = getSection("about.mission"); + const origin = getSection("about.origin"); + const values = getSection("about.values"); + const setUp = getSection("about.set-up"); + const openCode = getSection("about.open-code"); + + return ( + <> + <title>About · Open Grant Flow +
+ + {(mission) => ( +
+

+ {mission.title} +

+
+ {mission.body} +
+
+ )} +
+ + + {(origin) => ( +
+

+ {origin.title} +

+
+
+ +
+
+ {origin.body} +
+
+
+ )} +
+ + + {(values) => ( +
+
+

+ {values.title} +

+ +
+
+ )} +
+ + + {(setUp) => ( +
+

+ {setUp.title} +

+
+ {setUp.body} +
+
+ )} +
+ + + {(openCode) => ( +
+

+ {openCode.title} +

+
+ {openCode.body} +
+ +
+ )} +
+
+ + ); +} diff --git a/frontend-typescript/src/pages/site/Contact.test.tsx b/frontend-typescript/src/pages/site/Contact.test.tsx new file mode 100644 index 00000000..6f21dfcf --- /dev/null +++ b/frontend-typescript/src/pages/site/Contact.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import ContactPage from "./Contact"; + +function renderPage() { + return render( + + + , + ); +} + +async function fillRequiredFields({ email = "jane@example.org" } = {}) { + await userEvent.type(screen.getByLabelText("Name"), "Jane Doe"); + if (email) await userEvent.type(screen.getByLabelText("Work email"), email); + await userEvent.type(screen.getByLabelText("Organisation"), "Acme Foundation"); + await userEvent.selectOptions(screen.getByLabelText("Organisation type"), "Foundation"); +} + +describe("ContactPage", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("renders the demo request intro, pilot programme and FAQ", () => { + renderPage(); + + expect( + screen.getByRole("heading", { name: "Start a conversation" }), + ).toBeInTheDocument(); + expect(document.getElementById("pilot")).toHaveTextContent("Become a Pilot Partner"); + expect(screen.getByText("What you receive")).toBeInTheDocument(); + expect(screen.getByText("What we ask")).toBeInTheDocument(); + expect(screen.getByText("Is Open Grant Flow free?")).toBeInTheDocument(); + }); + + it("links the form to the privacy policy", () => { + renderPage(); + + expect(screen.getByRole("link", { name: "privacy policy" })).toHaveAttribute( + "href", + "/legal#privacy", + ); + }); + + it("sends a complete demo request, including organisation type and pilot interest", async () => { + const fetchMock = vi + .spyOn(global, "fetch") + .mockResolvedValue(new Response("{}", { status: 200 })); + renderPage(); + + await fillRequiredFields(); + await userEvent.type( + screen.getByLabelText(/How long does a donor report take/), + "About two weeks", + ); + await userEvent.click( + screen.getByLabelText("I'm interested in becoming a Pilot Partner"), + ); + await userEvent.click(screen.getByRole("button", { name: "Request a demo" })); + + expect(await screen.findByText(/we've received your request/i)).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.web3forms.com/submit", + expect.objectContaining({ method: "POST" }), + ); + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body).toMatchObject({ + name: "Jane Doe", + email: "jane@example.org", + organisation: "Acme Foundation", + organisation_type: "Foundation", + reporting_time: "About two weeks", + pilot_interest: true, + }); + }); + + it("does not send the request and flags the field when the work email is missing", async () => { + const fetchMock = vi.spyOn(global, "fetch"); + renderPage(); + + await fillRequiredFields({ email: "" }); + await userEvent.click(screen.getByRole("button", { name: "Request a demo" })); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Work email")).toHaveAttribute("aria-invalid", "true"); + }); + + it("shows an error message when the submission fails", async () => { + vi.spyOn(global, "fetch").mockRejectedValue(new Error("network error")); + renderPage(); + + await fillRequiredFields(); + await userEvent.click(screen.getByRole("button", { name: "Request a demo" })); + + expect( + await screen.findByText(/something went wrong sending your request/i), + ).toBeInTheDocument(); + }); + + it("hides the draft pilot details and time-expectations answer in production", () => { + vi.stubEnv("PROD", true); + + renderPage(); + + expect(screen.queryByText("How the pilot runs")).not.toBeInTheDocument(); + expect(screen.queryByText("How much time does a pilot take?")).not.toBeInTheDocument(); + expect(screen.queryByText("TBC")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-typescript/src/pages/site/Contact.tsx b/frontend-typescript/src/pages/site/Contact.tsx new file mode 100644 index 00000000..d13d8a43 --- /dev/null +++ b/frontend-typescript/src/pages/site/Contact.tsx @@ -0,0 +1,133 @@ +import { getSection, type SiteSection } from "@/lib/siteContent"; +import { SiteMarkdown } from "@/components/site/SiteMarkdown"; +import { SectionVisibility } from "@/components/site/SectionVisibility"; +import { DemoRequestForm } from "@/components/site/DemoRequestForm"; + +function ItemList({ section }: { section: SiteSection | null }) { + return ( + + {(section) => ( +
+

+ {section.title} +

+
    + {section.items?.map((item, index) => ( +
  • +

    + {item.title} +

    +

    {item.body}

    +
  • + ))} +
+
+ )} +
+ ); +} + +export default function ContactPage() { + const demoRequest = getSection("contact.demo-request"); + const pilot = getSection("contact.pilot"); + const pilotDetails = getSection("contact.pilot-details"); + const whatYouReceive = getSection("contact.what-you-receive"); + const whatWeAsk = getSection("contact.what-we-ask"); + const faq = getSection("contact.faq"); + const faqTime = getSection("contact.faq-time"); + + return ( + <> + Contact · Open Grant Flow +
+ + {(demoRequest) => ( +
+

+ {demoRequest.title} +

+
+ {demoRequest.body} +
+ +
+ )} +
+ + {(pilot || pilotDetails || whatYouReceive || whatWeAsk) && ( +
+
+ + {(pilot) => ( +
+

+ {pilot.title} +

+
+ {pilot.body} +
+
+ )} +
+ + + {(pilotDetails) => ( +
+

+ {pilotDetails.title} +

+
+ {pilotDetails.body} +
+
+ )} +
+ +
+ + +
+
+
+ )} + + {(faq || faqTime) && ( +
+ + {(faq) => ( +
+

+ {faq.title} +

+
+ {faq.items?.map((item, index) => ( +
+
+ {item.title} +
+
{item.body}
+
+ ))} +
+
+ )} +
+ + + {(faqTime) => ( +
+

+ {faqTime.title} +

+
+ {faqTime.body} +
+
+ )} +
+
+ )} +
+ + ); +} diff --git a/frontend-typescript/src/pages/site/HowItWorks.tsx b/frontend-typescript/src/pages/site/HowItWorks.tsx index c8da2901..8a0ff618 100644 --- a/frontend-typescript/src/pages/site/HowItWorks.tsx +++ b/frontend-typescript/src/pages/site/HowItWorks.tsx @@ -1,7 +1,6 @@ import { getSection } from "@/lib/siteContent"; import { SiteMarkdown } from "@/components/site/SiteMarkdown"; import { SectionVisibility } from "@/components/site/SectionVisibility"; -import { brand } from "@/lib/brand"; export default function HowItWorksPage() { const intro = getSection("how-it-works.intro"); @@ -18,14 +17,14 @@ export default function HowItWorksPage() { return ( <> How it works · Open Grant Flow -
+
{(intro) => (
-

+

{intro.title}

-
+
{intro.body}
@@ -36,8 +35,7 @@ export default function HowItWorksPage() { {(fiveSteps) => (

{fiveSteps.title}

@@ -48,12 +46,11 @@ export default function HowItWorksPage() { className="bg-white rounded-2xl card-shadow-lg p-6" >

Step {index + 1}

-

+

{step.title}

{step.body}

@@ -68,13 +65,11 @@ export default function HowItWorksPage() { {(sectorValidation) => (

{sectorValidation.title}

@@ -85,10 +80,10 @@ export default function HowItWorksPage() { key={index} className="bg-white rounded-2xl card-shadow-lg p-6 flex flex-col gap-4" > -

+

“{quote.quote}”

-

+

{quote.attribution}

@@ -98,21 +93,19 @@ export default function HowItWorksPage() { {anecdotes.map((anecdote, index) => (
-

+

{anecdote.body}

-

+

{anecdote.title}

))}

{sectorValidation.body}

@@ -124,10 +117,10 @@ export default function HowItWorksPage() { {(surveyEvidence) => (
-

+

{surveyEvidence.title}

-
+
{surveyEvidence.body}
@@ -135,11 +128,10 @@ export default function HowItWorksPage() {
{(whoGrantees || whoFunders) && ( -
+

Who is this for?

@@ -149,8 +141,7 @@ export default function HowItWorksPage() { {(section) => (

{section.title}

@@ -161,8 +152,7 @@ export default function HowItWorksPage() { className="bg-white p-6 rounded-2xl card-shadow-lg" >

{item.title}

@@ -182,10 +172,10 @@ export default function HowItWorksPage() { {(roadmap) => (
-

+

{roadmap.title}

-
+
{roadmap.body}
diff --git a/frontend-typescript/src/pages/site/Security.tsx b/frontend-typescript/src/pages/site/Security.tsx index dc333dbe..739e7a2e 100644 --- a/frontend-typescript/src/pages/site/Security.tsx +++ b/frontend-typescript/src/pages/site/Security.tsx @@ -1,17 +1,16 @@ import { getSection, type SiteSection } from "@/lib/siteContent"; import { SiteMarkdown } from "@/components/site/SiteMarkdown"; import { SectionVisibility } from "@/components/site/SectionVisibility"; -import { brand } from "@/lib/brand"; function ProseSection({ section }: { section: SiteSection | null }) { return ( {(section) => (
-

+

{section.title}

-
+
{section.body}
@@ -31,17 +30,17 @@ export default function SecurityPage() { return ( <> Security & data · Open Grant Flow -
+
{(intro) => (
-

+

{intro.title}

-
+
{intro.body}
diff --git a/frontend-typescript/src/pages/site/publicRoutes.test.tsx b/frontend-typescript/src/pages/site/publicRoutes.test.tsx index 9a612ce3..c9726ecf 100644 --- a/frontend-typescript/src/pages/site/publicRoutes.test.tsx +++ b/frontend-typescript/src/pages/site/publicRoutes.test.tsx @@ -74,6 +74,33 @@ describe("public site routing", () => { }, ); + it.each([ + ["/about", "Built from lived experience", "About · Open Grant Flow"], + ["/contact", "Start a conversation", "Contact · Open Grant Flow"], + ])("renders %s directly with its title (anonymous and authenticated)", async (path, heading, title) => { + for (const authenticated of [false, true]) { + localStorage.clear(); + if (authenticated) signInAsVerifiedUser(); + const { unmount } = renderAppAt(path); + + expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument(); + expect(window.location.pathname).toBe(path); + expect(document.title).toBe(title); + unmount(); + } + }); + + it("points every Request Demo CTA in the shared header to /contact", async () => { + renderAppAt("/about"); + await userEvent.click(screen.getByRole("button", { name: "Toggle navigation menu" })); + + const ctas = screen.getAllByRole("link", { name: "Request Demo" }); + expect(ctas.length).toBeGreaterThanOrEqual(2); + for (const cta of ctas) { + expect(cta).toHaveAttribute("href", "/contact"); + } + }); + it("renders How it works directly for an anonymous visitor, with its title", async () => { renderAppAt("/how-it-works"); diff --git a/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx index df00e47a..f87d0d82 100644 --- a/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx +++ b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx @@ -1,8 +1,11 @@ +import type { ComponentType } from "react"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { buildSiteContent, getSection } from "@/lib/siteContent"; +import { buildSiteContent, getSection, type SitePage } from "@/lib/siteContent"; import HowItWorksPage from "./HowItWorks"; import SecurityPage from "./Security"; +import AboutPage from "./About"; +import ContactPage from "./Contact"; vi.mock("@/lib/siteContent", async (importOriginal) => ({ ...await importOriginal(), @@ -14,6 +17,12 @@ const files = import.meta.glob( { eager: true, query: "?raw", import: "default" }, ) as Record; const sections = [...buildSiteContent(files).sections.values()]; +const PAGES: Partial> = { + "how-it-works": HowItWorksPage, + security: SecurityPage, + about: AboutPage, + contact: ContactPage, +}; describe.each(sections)("$id publication lifecycle", (section) => { it.each([false, true])("handles draft content (production: %s)", (isProd) => { @@ -24,7 +33,8 @@ describe.each(sections)("$id publication lifecycle", (section) => { : raw.replace(/status: (published|draft)/, "status: published"), ])); vi.mocked(getSection).mockImplementation(buildSiteContent(changed, { isProd }).getSection); - const Page = section.page === "security" ? SecurityPage : HowItWorksPage; + const Page = PAGES[section.page]; + if (!Page) throw new Error(`No page component mapped for "${section.page}"`); render(); if (isProd) { diff --git a/frontend-typescript/src/style.css b/frontend-typescript/src/style.css index 85e85c12..f69f43ad 100644 --- a/frontend-typescript/src/style.css +++ b/frontend-typescript/src/style.css @@ -1,5 +1,17 @@ @import "tailwindcss"; +/* Brand palette: generates bg-brand-*, text-brand-*, border-brand-* utilities. */ +@theme { + --color-brand-navy: #163A5F; + --color-brand-teal: #1F8A70; + --color-brand-gold: #D9A441; + --color-brand-off-white: #F8FAFC; + --color-brand-slate: #243447; + --color-brand-mist: #EEF2F6; + --color-brand-teal-tint: #EDF6F3; + --color-brand-teal-tint-border: #CDE7DE; +} + /* Type-scale tokens from the budget-report-frontend mockup (openspec/changes/budget-report-frontend, ticket #160). Adopted going forward on new/touched components — not retrofitted across the whole app diff --git a/openspec/changes/frontend-feat-372-public-site-content/tasks.md b/openspec/changes/frontend-feat-372-public-site-content/tasks.md index 9d202368..b7a0a943 100644 --- a/openspec/changes/frontend-feat-372-public-site-content/tasks.md +++ b/openspec/changes/frontend-feat-372-public-site-content/tasks.md @@ -32,10 +32,10 @@ _Generated by `scripts/flow.py sync frontend-feat-372-public-site-content` — d ## 2. About and Contact pages — ticket depends on 1 — Issue #374 - [ ] 2.0 Run `scripts/flow.py start frontend-feat-public-site-content 2` to move this group to In Progress and create its branch before starting any other work in this group. -- [ ] 2.1 Author the About content files (mission, origin story, values, "how we are set up" with the CIC status as `draft`, open-code link) and build `src/pages/site/About.tsx`. Verify with a render test. -- [ ] 2.2 Author the Contact content files: pilot programme (`anchor: pilot`, with month details as `draft`), what you receive and what we ask, demo-request intro, and FAQ items (the time-expectations answer as `draft`). -- [ ] 2.3 Build `src/pages/site/Contact.tsx` with the demo-request form moved out of `LandingPage.tsx`. Add the organisation-type select, the optional reporting-time question and the pilot-interest checkbox, all sent through the existing web3forms submission. Verify with tests for the successful-submit (mocked `fetch`, payload includes the new fields) and missing-work-email scenarios. -- [ ] 2.4 Register `/about` and `/contact` in `App.tsx` with titles. Point every "Request Demo" CTA, including the current landing nav, to `/contact`. Verify with the routing test and a nav test asserting `href="/contact"`. +- [x] 2.1 Author the About content files (mission, origin story, values, "how we are set up" with the CIC status as `draft`, open-code link) and build `src/pages/site/About.tsx`. Verify with a render test. +- [x] 2.2 Author the Contact content files: pilot programme (`anchor: pilot`, with month details as `draft`), what you receive and what we ask, demo-request intro, and FAQ items (the time-expectations answer as `draft`). +- [x] 2.3 Build `src/pages/site/Contact.tsx` with the demo-request form moved out of `LandingPage.tsx`. Add the organisation-type select, the optional reporting-time question and the pilot-interest checkbox, all sent through the existing web3forms submission. Verify with tests for the successful-submit (mocked `fetch`, payload includes the new fields) and missing-work-email scenarios. +- [x] 2.4 Register `/about` and `/contact` in `App.tsx` with titles. Point every "Request Demo" CTA, including the current landing nav, to `/contact`. Verify with the routing test and a nav test asserting `href="/contact"`. - [ ] 2.5 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. ## 3. Home page from content and retirement of the single-page landing — ticket depends on 1, 2 — Issue #375 From 2154f4ea07ea03d67e94361d3cf84dc82674761c Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Thu, 1 Oct 2026 16:13:20 +0100 Subject: [PATCH 3/6] feat: replace LandingPage with HomePage and implement content-driven structure - Created HomePage component to replace the deprecated LandingPage. - Implemented legacy hash redirects for navigation. - Added tests for HomePage to ensure proper rendering and functionality. - Removed LandingPage.tsx and its associated tests. - Verified that all relevant assertions were migrated to the new tests. - Conducted a production build check to ensure stability and performance. --- frontend-typescript/src/App.tsx | 4 +- .../src/components/site/ProductDemo.tsx | 24 + .../src/content/site/home/browse-instead.md | 20 + .../src/content/site/home/hero.md | 18 + .../src/content/site/home/pilot-teaser.md | 12 + .../src/content/site/home/values-summary.md | 15 + .../content/site/home/what-you-get-back.md | 17 + frontend-typescript/src/lib/siteContent.ts | 1 + .../src/pages/LandingPage.test.tsx | 170 ---- frontend-typescript/src/pages/LandingPage.tsx | 762 ------------------ .../src/pages/site/Home.test.tsx | 127 +++ frontend-typescript/src/pages/site/Home.tsx | 189 +++++ .../src/pages/site/sectionLifecycle.test.tsx | 5 +- .../tasks.md | 8 +- 14 files changed, 433 insertions(+), 939 deletions(-) create mode 100644 frontend-typescript/src/components/site/ProductDemo.tsx create mode 100644 frontend-typescript/src/content/site/home/browse-instead.md create mode 100644 frontend-typescript/src/content/site/home/hero.md create mode 100644 frontend-typescript/src/content/site/home/pilot-teaser.md create mode 100644 frontend-typescript/src/content/site/home/values-summary.md create mode 100644 frontend-typescript/src/content/site/home/what-you-get-back.md delete mode 100644 frontend-typescript/src/pages/LandingPage.test.tsx delete mode 100644 frontend-typescript/src/pages/LandingPage.tsx create mode 100644 frontend-typescript/src/pages/site/Home.test.tsx create mode 100644 frontend-typescript/src/pages/site/Home.tsx diff --git a/frontend-typescript/src/App.tsx b/frontend-typescript/src/App.tsx index a19e2b11..1234f679 100644 --- a/frontend-typescript/src/App.tsx +++ b/frontend-typescript/src/App.tsx @@ -2,9 +2,9 @@ import { Routes, Route, Navigate, BrowserRouter, Outlet } from "react-router-dom import { useAuth, AuthProvider } from "./context/AuthContext"; import { AiChatProvider } from "./context/AiChatContext"; import Login from "./pages/Login"; -import LandingPage from "./pages/LandingPage"; import LegalPage from "./pages/Legal"; import PublicLayout from "./components/site/PublicLayout"; +import HomePage from "./pages/site/Home"; import HowItWorksPage from "./pages/site/HowItWorks"; import SecurityPage from "./pages/site/Security"; import AboutPage from "./pages/site/About"; @@ -50,7 +50,7 @@ export default function App() { }> - } /> + } /> } /> } /> } /> diff --git a/frontend-typescript/src/components/site/ProductDemo.tsx b/frontend-typescript/src/components/site/ProductDemo.tsx new file mode 100644 index 00000000..c27890ed --- /dev/null +++ b/frontend-typescript/src/components/site/ProductDemo.tsx @@ -0,0 +1,24 @@ +export function ProductDemo() { + return ( +
+
+

+ See It In Action +

+

+ Watch a 30 seconds product tour +

+
+
+