diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml
index 068598c2d1..5a677c8998 100644
--- a/.github/workflows/website-ci.yml
+++ b/.github/workflows/website-ci.yml
@@ -133,60 +133,95 @@ jobs:
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
with:
script: |
- const fs = require('fs');
- const steps = [
- { name: 'Checkout', id: 'checkout', emoji: '๐ฆ' },
- { name: 'Setup Node', id: 'setup-node', emoji: 'โ๏ธ' },
- { name: 'Install dependencies', id: 'install', emoji: '๐ฅ' },
- { name: 'Lint', id: 'lint', emoji: '๐' },
- { name: 'Type Check', id: 'typecheck', emoji: '๐' },
- { name: 'Unit Tests & Coverage', id: 'tests', emoji: '๐งช' }
+ const stepMeta = [
+ { name: 'Checkout', id: 'checkout', emoji: '๐ฆ', fix: null },
+ { name: 'Setup Node', id: 'setup-node', emoji: 'โ๏ธ', fix: null },
+ { name: 'Install dependencies', id: 'install', emoji: '๐ฅ', fix: '`rm -rf node_modules && npm ci`' },
+ { name: 'Lint', id: 'lint', emoji: '๐', fix: '`npm run lint:fix`' },
+ { name: 'Type Check', id: 'typecheck', emoji: '๐', fix: 'Fix TypeScript errors listed below' },
+ { name: 'Unit Tests & Coverage', id: 'tests', emoji: '๐งช', fix: '`npm run test:coverage` locally' }
];
+ // Fetch jobs via API (workflow_run payload doesn't include step outcomes)
+ const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: context.runId,
+ per_page: 100
+ });
+
const failedSteps = [];
- for (const step of steps) {
- const outcome = context.payload.workflow_run?.jobs?.[0]?.steps?.find(
- s => s.name === step.name
- )?.outcome;
- if (outcome === 'failed') {
- failedSteps.push(step);
+ const failedLogs = [];
+
+ for (const job of jobsData.jobs || []) {
+ for (const step of job.steps || []) {
+ if (step.conclusion === 'failure') {
+ const meta = stepMeta.find(s => s.name === step.name);
+ if (meta) {
+ failedSteps.push({ ...meta, apiName: step.name });
+ } else {
+ failedSteps.push({ name: step.name, id: 'unknown', emoji: 'โ', fix: null, apiName: step.name });
+ }
+
+ // Attempt to fetch the step log for context
+ try {
+ const { data: logs } = await github.rest.actions.downloadJobLogsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ job_id: job.id
+ });
+ if (logs) {
+ failedLogs.push({ step: step.name, logUrl: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/job/${job.id}` });
+ }
+ } catch (e) {
+ // Logs may not be available; continue without them
+ }
+ }
}
}
- // Fallback: if we couldn't determine exact step, assume tests or lint
+ // Fallback when step detection fails
if (failedSteps.length === 0) {
- failedSteps.push({ name: 'Unknown step', id: 'unknown', emoji: 'โ' });
+ failedSteps.push({ name: 'Build', id: 'build', emoji: 'โ', fix: 'Check the workflow logs for details' });
}
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- const failedList = failedSteps.map(s => `${s.emoji} **${s.name}**`).join('\n');
-
- const body = [
- '## โ Website CI Failed',
- '',
- `**Failed step(s):**`,
- failedList,
- '',
- '### Next Steps',
- '',
- ];
- if (failedSteps.some(s => s.id === 'lint')) {
- body.push('- Run `npm run lint:fix` to auto-fix lint issues');
- }
- if (failedSteps.some(s => s.id === 'typecheck')) {
- body.push('- Check TypeScript errors above and fix type issues');
+ // Build the comment body
+ const body = [];
+ body.push('## โ Website CI Failed');
+ body.push('');
+
+ // Failed steps summary
+ body.push('**Failed step(s):**');
+ body.push('');
+ for (const s of failedSteps) {
+ body.push(`- ${s.emoji} **${s.name}**`);
}
- if (failedSteps.some(s => s.id === 'tests')) {
- body.push('- Run `npm run test:coverage` locally to see test failures');
+ body.push('');
+
+ // Actionable next steps
+ body.push('### How to Fix');
+ body.push('');
+ for (const s of failedSteps) {
+ if (s.fix) {
+ body.push(`- **${s.name}:** ${s.fix}`);
+ }
}
- if (failedSteps.some(s => s.id === 'install')) {
- body.push('- Check for dependency issues, try `rm -rf node_modules && npm install`');
+ body.push('');
+
+ // Links
+ body.push('### Resources');
+ body.push('');
+ body.push(`- [View full workflow run](${runUrl})`);
+ if (failedLogs.length > 0) {
+ for (const log of failedLogs) {
+ body.push(`- [View logs for \`${log.step}\`](${log.logUrl})`);
+ }
}
-
- body.push('', `๐ [View workflow run](${runUrl})`);
- body.push('', '---');
- body.push('*Please fix the issues and push again.*');
+ body.push('');
+ body.push('---');
+ body.push('*Please fix the issues and push again to re-trigger CI.*');
// Find and update existing comment or create new one
const marker = '## โ Website CI Failed';
@@ -196,8 +231,7 @@ jobs:
issue_number: context.issue.number,
per_page: 100
});
-
- const existing = comments.find(c => c.body.includes(marker));
+ const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
diff --git a/docs/clean-air-forum-followups.md b/docs/clean-air-forum-followups.md
new file mode 100644
index 0000000000..291e3bde04
--- /dev/null
+++ b/docs/clean-air-forum-followups.md
@@ -0,0 +1,119 @@
+# Clean Air Forum selfie feature โ follow-ups for other teams
+
+This feature (mobile selfie filter/sticker + `/selfies` conference wall
+display) was built as a **temporary, fully client-side-and-mock-API
+implementation** so it could be demoed end-to-end before any backend or
+infra work existed for it. Everything below is scaffolding, explicitly
+marked `TEMPORARY` in code, and needs real ownership before/soon after the
+event it launches with.
+
+Related PRs: [#3755](https://github.com/airqo-platform/AirQo-frontend/pull/3755) (mobile), [#3756](https://github.com/airqo-platform/AirQo-frontend/pull/3756) (website)
+
+## 1. Backend team โ replace the mock submissions API
+
+**What exists today**: `src/website/src/services/cleanAirForumSelfiesStore.ts`
+is a plain in-memory array behind `src/website/src/app/api/clean-air-forum/selfies/`
+(`GET`/`POST`) and `.../selfies/[id]/` (`PATCH` to hide). It:
+
+- Resets on every redeploy and isn't shared across replicas โ a submission
+ can vanish mid-event if the website pod restarts or scales out.
+- Has no real auth beyond a shared secret/PIN (see ยง3) โ fine for a
+ short-lived event, not something to keep long-term.
+
+**What's needed**: equivalent endpoints on the real AirQo backend, with
+persistent storage (even a simple table is enough โ submissions are
+small: image URL + AQI metadata + timestamp + hidden flag). The
+request/response shapes in the mock were deliberately kept simple so they
+can carry over almost unchanged:
+
+- `POST /selfies` โ body `{ eventId, imageUrl, locationName?, pm25Value?, aqiCategory?, displayName? }` โ `201 { submission }`
+- `GET /selfies?eventId=...` โ โ `200 { submissions: [...] }`, newest first, hidden ones excluded
+- `PATCH /selfies/:id` โ hides (soft-deletes) a submission, used by wall moderation
+
+Once these exist, the two Next.js routes above should just proxy to them
+(or be deleted in favor of calling the real API directly from
+`SelfiesWallPage.tsx` and the mobile app).
+
+## 2. Cloudinary / whoever owns that account โ harden the upload preset
+
+Selfies are uploaded directly from the mobile app to Cloudinary using an
+**unsigned upload preset** (`NEXT_PUBLIC_CLOUDINARY_NAME` /
+`NEXT_PUBLIC_CLOUDINARY_PRESET`, read in
+`src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart`).
+Unsigned presets are bundled in the app binary by design (that's how
+unsigned uploads work), so anyone who decompiles the app can extract the
+preset name and cloud name and upload directly to this Cloudinary account.
+
+Please apply, at minimum, on the `clean_air_forum_selfies` preset:
+
+- **Folder lock** โ restrict the preset to only write into
+ `clean_air_forum_selfies/` (prevents overwriting/polluting other
+ folders in the account).
+- **File size / format limits** โ cap to a few MB, images only.
+- **Moderation** โ consider enabling Cloudinary's built-in moderation
+ add-on (or at least manual review capability) given this feeds a public
+ venue display.
+- Longer-term: move to a **signed upload** flow (mobile app calls our own
+ backend for a signature, backend calls Cloudinary) once the real backend
+ from ยง1 exists โ this closes the preset-exposure issue completely
+ instead of just mitigating it.
+
+The website route already validates that submitted `imageUrl`s are
+`https://res.cloudinary.com/.../clean_air_forum_selfies/...` before
+accepting them (defense in depth), but that alone doesn't stop someone
+with the preset from uploading directly to Cloudinary โ it only stops them
+from getting arbitrary *other* URLs onto the wall via our API.
+
+## 3. DevOps / whoever manages GitHub Actions secrets โ wire up new env vars
+
+Two new secrets need to be created and referenced in the deploy workflows
+(`.github/workflows/deploy-frontends-to-staging.yml`,
+`deploy-frontends-to-production.yml`, the Azure equivalents, and
+`deploy-frontend-pr-previews.yml` if selfie testing on PR previews matters).
+These are **server-only** vars (no `NEXT_PUBLIC_` prefix), so they only need
+adding to each workflow's `.env.yaml` generation step (same pattern as
+`SLACK_WEBHOOK_URL`) โ no Docker `--build-arg` needed.
+
+| Env var | Where it's used | What it does |
+|---|---|---|
+| `CLEAN_AIR_FORUM_WALL_PIN` | `src/website/src/app/api/clean-air-forum/selfies/[id]/route.ts` | Staff PIN required to remove a photo from the wall via double-tap/long-press |
+| `CLEAN_AIR_FORUM_SUBMISSION_SECRET` | `src/website/src/app/api/clean-air-forum/selfies/route.ts` | Shared secret the mobile app sends (`x-clean-air-forum-secret` header) so only the app can post to the wall |
+
+**Important**: both checks now **fail closed in production** if the
+corresponding env var is missing (they only fail open in
+development/preview, for local testing convenience) โ see
+`src/website/src/services/cleanAirForumAuth.ts`. If these aren't
+configured before a production deploy, moderation and/or submissions will
+be rejected outright (with a `console.warn` in the server logs), not
+silently left open. **Please set both before the forum goes live in
+production.**
+
+The mobile app also needs a matching secret in its own env
+(`CLEAN_AIR_FORUM_API_SECRET` in `src/mobile/.env.prod` /
+`.env.dev` โ not committed to the repo) equal to whatever
+`CLEAN_AIR_FORUM_SUBMISSION_SECRET` is set to on the website side.
+
+## 4. Rate limiting โ needs a real (distributed) solution
+
+`route.ts` currently has a best-effort **in-memory, per-server-instance**
+rate limit (5 submissions/IP/minute) as a stopgap โ it resets per instance
+and doesn't hold across replicas, so it only blunts casual spam, not a
+determined abuser hitting a load-balanced deployment. If abuse becomes a
+real concern for the event, this needs a proper distributed rate limiter
+(e.g. Cloudflare in front of the site, or a Redis/Upstash-backed limiter)
+โ that's an infra decision beyond what this PR can set up.
+
+## 5. Content moderation policy
+
+Right now, moderation is manual only: a staff member double-taps or
+long-presses a bad photo on the wall display itself and enters a shared
+PIN. There's no automated image moderation (nudity/violence detection,
+etc.). Given this is a public-facing conference wall, it may be worth
+asking whoever owns Cloudinary to enable an automated moderation add-on
+(see ยง2) as a first line of defense, with manual removal as the backstop.
+
+## Event details
+
+`src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart`
+has `edition = 'Pretoria 2026'` and `dateRange = '13TH-16TH JULY'` โ both
+confirmed correct as of 2026-07-07. No action needed here.
diff --git a/src/website/AGENTS.md b/src/website/AGENTS.md
index 835eb7288d..9ffdd2f7cd 100644
--- a/src/website/AGENTS.md
+++ b/src/website/AGENTS.md
@@ -5,53 +5,222 @@
- Next.js 14 App Router (standalone output)
- React 18 + TypeScript 5.5
- Tailwind CSS 3 + shadcn/ui (new-york style)
-- Redux Toolkit + Tanstack React Query
-- Jest + Testing Library (jsdom)
+- Redux Toolkit + Tanstack React Query v5
+- Jest 29 + Testing Library (jsdom)
+- Framer Motion for animations
## Commands
```bash
npm run dev # dev server on localhost:3000
npm run build # production build
+npm run start # start production server
npm run lint # ESLint
npm run lint:fix # ESLint auto-fix
-npm run format # Prettier
-npm run test # Jest
+npm run format # Prettier (write all files)
+npm run test # Jest unit tests
+npm run test:watch # Jest in watch mode
+npm run test:coverage # Jest with coverage report
+npm run e2e # E2E tests (headless, Mocha + Selenium)
+npm run e2e:headed # E2E tests with visible browser
```
Run from `src/website/`. No monorepo task runner; each app is independent.
+**Validation**: After making changes, always run:
+
+```bash
+npm run lint && npm run format && npm run build
+```
+
## Project Structure
-- `src/app/` - Next.js routes. Route groups `(main)` and `(main)/(about)` organize pages without affecting URLs.
-- `src/views/` - Page components (actual UI logic). App pages are thin wrappers that delegate here.
-- `src/components/` - Reusable components. `ui/` has shadcn primitives, `layouts/` has MainLayout/Navbar/Footer.
-- `src/services/` - API client and hooks. `apiClient.ts` handles server vs client routing.
-- `src/store/` - Redux slices.
-- `src/configs/` - Static config data (package listings, etc.).
-- `src/lib/utils.ts` - `cn()` helper for Tailwind class merging.
+```
+src/
+โโโ __mocks__/ # Jest mocks (fileMock, mapbox-gl, recharts)
+โโโ app/ # Next.js App Router routes and layouts
+โ โโโ (site)/ # Main site pages (home, about, careers, contact, events, etc.)
+โ โโโ (products)/ # Product pages (8 routes)
+โ โโโ (solutions)/ # Solution pages (5 routes)
+โ โโโ (programs)/ # Program pages (africa-clean-air-forum, faces-of-clean-air)
+โ โโโ (content)/ # Content pages (blogs)
+โ โโโ (developers)/ # Developer pages (devcon, packages)
+โ โโโ (legal)/ # Legal pages (terms, privacy, cookies)
+โ โโโ (utility)/ # Utility pages (explore-data, billboard)
+โ โโโ api/ # API routes (proxy, geocode, translate, log)
+โโโ components/ # Reusable UI components
+โ โโโ ui/ # shadcn/ui primitives (18 components)
+โ โโโ layout/ # Layout shells (MainLayout, Navbar, Footer, etc.)
+โ โโโ providers/ # Context providers (Redux, Query, ForumData)
+โ โโโ sections/ # Page sections (AirQualityBillboard, footer, etc.)
+โ โโโ feedback/ # Feedback components (EmptyState, ErrorState, LoadingState)
+โ โโโ dialogs/ # Dialog components (EngagementDialog, LanguageModal)
+โโโ config/ # Static configuration data
+โ โโโ cleanAirForumConfig.ts # Clean Air Forum constants
+โ โโโ env.config.ts # Environment variable validation
+โ โโโ navigation.config.ts # Navigation structure
+โ โโโ packages.config.ts # Package listings
+โ โโโ routes.config.ts # Route definitions
+โ โโโ seo.config.ts # SEO defaults
+โ โโโ site.config.ts # Site metadata
+โโโ features/ # Feature modules (page-level UI logic)
+โ โโโ about/ # About page with components, hooks, services
+โ โโโ blogs/ # Blog listing and detail pages
+โ โโโ careers/ # Career listing and detail pages
+โ โโโ clean-air-forum/ # Africa Clean Air Forum (11 sub-features)
+โ โโโ clean-air-network/ # Clean Air Network
+โ โโโ contact/ # Contact form
+โ โโโ developers/ # Developer pages (DevCon)
+โ โโโ events/ # Event listing and detail
+โ โโโ explore-data/ # Data exploration
+โ โโโ faces-of-clean-air/ # Faces of Clean Air carousel page
+โ โโโ faqs/ # FAQ page
+โ โโโ home/ # Home page with deferred sections
+โ โโโ legal/ # Legal pages (TOS, Privacy, etc.)
+โ โโโ packages/ # Icon packages browser
+โ โโโ partners/ # Partners listing
+โ โโโ press/ # Press/media page
+โ โโโ products/ # Product marketing pages (8 products)
+โ โโโ resources/ # Resources page
+โ โโโ solutions/ # Solution pages (5 solutions)
+โโโ hooks/ # Custom React hooks
+โโโ lib/ # Utilities and helpers
+โ โโโ utils/ # Utility functions (cn, formatDate, slugify, etc.)
+โ โโโ security/ # Security utilities (sanitizeHtml, safeExternalLink)
+โ โโโ analytics/ # Analytics utilities
+โ โโโ metadata/ # SEO metadata generation
+โโโ queries/ # Tanstack React Query definitions
+โโโ services/ # API client and service layer
+โ โโโ api/ # Core API layer (client, routes, base service)
+โ โโโ external/ # External integrations (analytics, cloudinary, maps, faces-of-clean-air)
+โ โโโ website/ # Website business services (20 service files)
+โโโ store/ # Redux Toolkit slices (country, forum, modal)
+โโโ styles/ # CSS files (theme, typography, utilities)
+โโโ types/ # TypeScript type definitions
+```
## Path Aliases
-Configured in `tsconfig.json`: `@/*` maps to `./src/*`. Also `@/components/*`, `@/utils/*`, `@/hooks/*`, `@/store/*`, `@/types/*`, `@/lib/*`, `@/services/*`, `@/context/*`, `@/views/*`, `@/configs/*`.
+Configured in `tsconfig.json`:
+
+- `@/*` -> `./src/*`
+- `@/components/*` -> `./src/components/*`
+- `@/lib/*` -> `./src/lib/*`
+- `@/hooks/*` -> `./src/hooks/*`
+- `@/store/*` -> `./src/store/*`
+- `@/types/*` -> `./src/types/*`
+- `@/services/*` -> `./src/services/*`
+- `@/features/*` -> `./src/features/*`
+- `@/config/*` -> `./src/config/*`
+- `@/queries/*` -> `./src/queries/*`
+- `@/styles/*` -> `./src/styles/*`
+- `@public/*` -> `./public/*`
## API Client
-`src/services/apiClient.ts` - Server-side calls use `API_URL` env var directly with `API_TOKEN` query param. Client-side calls go through Next.js proxy at `/api/v2`. Do not hard-code backend URLs.
+`src/services/api/api-client.ts` - Dual-mode API client:
+
+- **Server-side**: Direct backend API call using `API_URL` env var with `API_TOKEN` query param
+- **Client-side**: Next.js proxy at `/api/v2` (hides backend from browser)
+
+**Important**: API routes in `api-routes.ts` are defined differently for server vs client:
+
+- `WEBSITE` routes use `/website/api/v2/...` prefix (server-side normalization handles client proxy)
+- `USERS` routes use `users/selfies` format (no `/api/v2/` prefix, client proxy adds it)
+- Never hard-code backend URLs; always use `API_ROUTES` constants
+
+## Services Structure
+
+### `src/services/api/` - Core API Layer
+
+- `api-client.ts` - Fetch-based API client with server/client dual routing
+- `api-routes.ts` - All API endpoint constants (WEBSITE, DEVICES, USERS, PREDICT, PAYMENTS)
+- `base.ts` - `BaseApiService` class with CRUD methods and pagination transformer
+- `api-error.ts` - Enhanced error types with status codes
+- `api-response.ts` - Response type definitions
+
+### `src/services/external/` - External Integrations
+
+- `analytics.service.ts` - Analytics and grid data
+- `cloudinary.service.ts` - Cloudinary image management
+- `faces-of-clean-air.service.ts` - Faces of Clean Air API
+- `maps.service.ts` - Map-related services
+
+### `src/services/website/` - Website Business Services (20 files)
+
+Services for blogs, careers, events, forum-events, grids, partners, press, publications, team, etc.
+
+## Features Pattern
+
+Pages use the **features pattern** (NOT views pattern):
+
+1. **App pages are thin wrappers** that delegate to feature components:
+
+ ```tsx
+ // src/app/(site)/home/page.tsx
+ import HomePage from '@/features/home/HomePage';
+
+ const page = () => ;
+ export default page;
+ ```
+
+2. **Route groups** organize pages without affecting URLs. Each group can have its own `layout.tsx`.
+
+3. **MainLayout** wraps most pages (navbar + footer). Exceptions: contact (custom layout), faces-of-clean-air (no layout wrapper).
## Environment Variables
-Copy `.env.sample` to `.env`. Server-side: `API_URL`, `API_TOKEN`, `OPENCAGE_API_KEY`, `SLACK_WEBHOOK_URL`, `SLACK_CHANNEL`, `GOOGLE_SITE_VERIFICATION`. Client-side: `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_GA_MEASUREMENT_ID`, `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`. Never commit `.env`.
+Copy `.env.sample` to `.env`. Never commit `.env`.
+
+### Server-side
+
+| Variable | Purpose |
+| -------------------------- | ------------------------------------- |
+| `API_URL` | Backend API base URL |
+| `API_TOKEN` | Authentication token for API requests |
+| `OPENCAGE_API_KEY` | OpenCage geocoding API key |
+| `SLACK_WEBHOOK_URL` | Slack notification webhook |
+| `SLACK_CHANNEL` | Slack notification channel |
+| `GOOGLE_SITE_VERIFICATION` | Google Search Console verification |
+
+### Client-side (NEXT*PUBLIC* prefix)
+
+| Variable | Purpose |
+| -------------------------------------- | -------------------------------------------------- |
+| `NEXT_PUBLIC_SITE_URL` | Comma-separated site URLs (first = canonical base) |
+| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics measurement ID |
+| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | Mapbox GL access token |
+| `NEXT_PUBLIC_CLEAN_AIR_FORUM_EVENT_ID` | Forum edition ID for faces-of-clean-air page |
When adding new env vars, update `.env.sample` and CI workflow YAML files in `.github/workflows/`.
## Linting
-ESLint enforces: `simple-import-sort` (auto-sorted imports), `unused-imports` (warn on unused), `prettier` formatting errors. `@typescript-eslint/no-explicit-any` is off. Fix order: `npm run lint:fix && npm run format`.
+ESLint enforces:
+
+- `simple-import-sort` (auto-sorted imports) - **error**
+- `unused-imports` (warn on unused)
+- `prettier` formatting errors
+- `@typescript-eslint/no-explicit-any` is **off**
+
+Fix order: `npm run lint:fix && npm run format`
## Testing
-Jest with `ts-jest` and `jsdom`. Module aliases mirror tsconfig. CSS mocked via `identity-obj-proxy`. No test files exist yet; use `@testing-library/react` patterns when adding tests.
+### Unit Tests (Jest)
+
+- Framework: Jest 29 with `ts-jest` preset (`js-with-ts-esm`)
+- Environment: `jsdom`
+- 36 test files across: `src/lib/utils/__tests__/`, `src/lib/security/__tests__/`, `src/store/slices/__tests__/`, `src/services/api/__tests__/`, `src/queries/__tests__/`, `src/hooks/__tests__/`, `src/config/__tests__/`
+- Module aliases mirror tsconfig
+- CSS mocked via `identity-obj-proxy`
+- Test patterns: `src/**/__tests__/**/*.{ts,tsx}` and `src/**/*.{spec,test}.{ts,tsx}`
+
+### E2E Tests (Mocha + Selenium)
+
+- Framework: Mocha 11 with `selenium-webdriver` 4
+- Config: `e2e/.mocharc.yml` (60s timeout)
+- 9 page tests + 4 navigation tests
## Docker
@@ -64,9 +233,11 @@ Requires `.env` file in the website directory.
## Key Conventions
-- Root `/` redirects to `/home`.
-- Pages use the views pattern: `app/[route]/page.tsx` delegates to `views/[route]/[Name]Page.tsx`.
-- MainLayout wraps most pages (navbar + footer). Exceptions: contact (no footer), partners (no footer), forum (custom layout).
+- Root `/` redirects to `/home` (via `next.config.mjs` redirect).
+- Pages use the features pattern: `app/[route]/page.tsx` delegates to `features/[route]/[Name]Page.tsx`.
+- MainLayout wraps most pages (navbar + footer). Exceptions: contact (custom layout), faces-of-clean-air (no layout wrapper).
- `cn()` from `@/lib/utils` for conditional Tailwind classes.
- Server-side API calls append `API_TOKEN` as query param automatically.
- `NEXT_PUBLIC_SITE_URL` is comma-separated; first entry is canonical base.
+- FloatingMiniBillboard is suppressed on `/packages`, `/solutions/network-coverage`, and `/faces-of-clean-air` routes.
+- Metadata is generated per-page using `METADATA_CONFIGS` from `@/lib/metadata`.
diff --git a/src/website/README.md b/src/website/README.md
index 7ce3bad8b6..42b06fe0ef 100644
--- a/src/website/README.md
+++ b/src/website/README.md
@@ -1,6 +1,6 @@
# Website
-   
+     
**Website** is the AirQo marketing and analytics application for air quality data visualization. Built with Next.js 14, React 18, TypeScript, Tailwind CSS, Redux Toolkit, and TanStack React Query. The live website is at [airqo.net](https://airqo.net).
diff --git a/src/website/public/assets/images/white-logo.png b/src/website/public/assets/images/white-logo.png
new file mode 100644
index 0000000000..0ae6ba5797
Binary files /dev/null and b/src/website/public/assets/images/white-logo.png differ
diff --git a/src/website/public/favicon.ico b/src/website/public/favicon.ico
new file mode 100644
index 0000000000..1a323901ae
Binary files /dev/null and b/src/website/public/favicon.ico differ
diff --git a/src/website/src/app/(programs)/faces-of-clean-air/page.tsx b/src/website/src/app/(programs)/faces-of-clean-air/page.tsx
new file mode 100644
index 0000000000..539f51fbd5
--- /dev/null
+++ b/src/website/src/app/(programs)/faces-of-clean-air/page.tsx
@@ -0,0 +1,15 @@
+import FacesOfCleanAirPage from '@/features/faces-of-clean-air/FacesOfCleanAirPage';
+import { generateMetadata } from '@/lib/metadata';
+
+export const metadata = generateMetadata({
+ title: 'Faces of Clean Air | AirQo',
+ description:
+ 'See the faces of clean air advocates from the Africa Clean Air Forum. View selfies shared by attendees showcasing air quality readings from their locations.',
+ keywords:
+ 'Faces of Clean Air, Africa Clean Air Forum selfies, air quality advocates, PM2.5 readings Africa, clean air community, air quality conference photos',
+ url: '/faces-of-clean-air',
+});
+
+export default function FacesOfCleanAirRoute() {
+ return ;
+}
diff --git a/src/website/src/app/layout.tsx b/src/website/src/app/layout.tsx
index a4e0d731f5..7f8ddf186d 100644
--- a/src/website/src/app/layout.tsx
+++ b/src/website/src/app/layout.tsx
@@ -178,7 +178,14 @@ export const metadata: Metadata = {
],
},
icons: {
- icon: '/web-app-manifest-192x192.png',
+ icon: [
+ { url: '/assets/images/white-logo.png', sizes: 'any', type: 'image/png' },
+ {
+ url: '/web-app-manifest-192x192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ },
+ ],
apple: '/web-app-manifest-192x192.png',
},
verification: {
@@ -313,6 +320,8 @@ export default async function RootLayout({
crossOrigin="anonymous"
/>
+
+
{/* Structured data */}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: Static JSON-LD data, not user input */}
-
-
+
+
+
+
+
+
diff --git a/src/website/src/components/FloatingMiniBillboard.tsx b/src/website/src/components/FloatingMiniBillboard.tsx
index b8f8112f36..775de6619c 100644
--- a/src/website/src/components/FloatingMiniBillboard.tsx
+++ b/src/website/src/components/FloatingMiniBillboard.tsx
@@ -85,6 +85,17 @@ export default function FloatingMiniBillboard({
} as React.CSSProperties;
}
+ // Use solid dark background for 'no-value' to ensure visibility
+ if (level === 'no-value') {
+ return {
+ backgroundColor: categoryColor,
+ color: '#ffffff',
+ border: `1px solid ${hexToRgba(categoryColor, 0.9)}`,
+ boxShadow: 'inset 0 -2px 0 rgba(0,0,0,0.06)',
+ fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
+ } as React.CSSProperties;
+ }
+
// Default subtle style for other categories
return {
backgroundColor: hexToRgba(categoryColor, 0.14),
diff --git a/src/website/src/components/FloatingMiniBillboardWrapper.tsx b/src/website/src/components/FloatingMiniBillboardWrapper.tsx
index 1a176e08ce..6189331fcd 100644
--- a/src/website/src/components/FloatingMiniBillboardWrapper.tsx
+++ b/src/website/src/components/FloatingMiniBillboardWrapper.tsx
@@ -53,7 +53,8 @@ export default function FloatingMiniBillboardWrapper() {
useEffect(() => {
if (
pathname?.startsWith('/packages') ||
- pathname?.startsWith('/solutions/network-coverage')
+ pathname?.startsWith('/solutions/network-coverage') ||
+ pathname?.startsWith('/faces-of-clean-air')
) {
// Suppressed routes: skip fetching to avoid unnecessary load
setLoading(false);
@@ -193,12 +194,13 @@ export default function FloatingMiniBillboardWrapper() {
};
}, [pathname]);
- // Don't render anything while loading or if there's no data or if on packages page
+ // Don't render anything while loading or if there's no data or if on suppressed pages
if (
loading ||
billboardData.length === 0 ||
pathname?.startsWith('/packages') ||
- pathname?.startsWith('/solutions/network-coverage')
+ pathname?.startsWith('/solutions/network-coverage') ||
+ pathname?.startsWith('/faces-of-clean-air')
) {
return null;
}
diff --git a/src/website/src/components/dialogs/EngagementDialog.tsx b/src/website/src/components/dialogs/EngagementDialog.tsx
index d9c1e60287..36a86eb0ce 100644
--- a/src/website/src/components/dialogs/EngagementDialog.tsx
+++ b/src/website/src/components/dialogs/EngagementDialog.tsx
@@ -442,7 +442,7 @@ const EngagementDialog = () => {
Access real-time and historic air quality information across
- Africa through our easy-to-use AirQo Nexus dashboard.
+ Africa through our easy-to-use AirQo Nexus.
@@ -165,21 +172,28 @@ const AirQoDevConPage = () => {
Register your interest now and we will share joining details and
preparation notes with registered students.
-