diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml new file mode 100644 index 0000000000..90043fe5f2 --- /dev/null +++ b/.github/workflows/website-ci.yml @@ -0,0 +1,60 @@ +name: website-ci + +on: + pull_request: + branches: + - staging + - main + paths: + - "src/website/**" + - ".github/workflows/website-ci.yml" + push: + branches: + - staging + paths: + - "src/website/**" + - ".github/workflows/website-ci.yml" + +permissions: + contents: read + +jobs: + check-website: + name: check-website + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/website + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af + with: + node-version: "20" + cache: "npm" + cache-dependency-path: src/website/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type Check + run: npx tsc --noEmit + + - name: Unit Tests & Coverage + run: npm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + continue-on-error: true + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: src/website/coverage/ + flags: website + fail_ci_if_error: false diff --git a/.github/workflows/website-codecov.yml b/.github/workflows/website-codecov.yml new file mode 100644 index 0000000000..57f41a5037 --- /dev/null +++ b/.github/workflows/website-codecov.yml @@ -0,0 +1,35 @@ +name: website-codecov + +on: + workflow_call: + inputs: + coverage-directory: + description: "Path to coverage directory" + required: false + type: string + default: "src/website/coverage/" + flag: + description: "Codecov flag name" + required: false + type: string + default: "website" + secrets: + CODECOV_TOKEN: + required: true + +permissions: + contents: read + +jobs: + upload-coverage: + name: Upload Website Coverage + runs-on: ubuntu-latest + steps: + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + continue-on-error: true + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ${{ inputs.coverage-directory }} + flags: ${{ inputs.flag }} + fail_ci_if_error: false diff --git a/src/website/.gitignore b/src/website/.gitignore index 3cb5f8f569..5d549c0764 100644 --- a/src/website/.gitignore +++ b/src/website/.gitignore @@ -8,6 +8,10 @@ # testing /coverage +/e2e/screenshots +/e2e/reports +/e2e/dist +mochawesome-report/ # next.js /.next/ diff --git a/src/website/README.md b/src/website/README.md index e73b563160..035159a6e1 100644 --- a/src/website/README.md +++ b/src/website/README.md @@ -1,316 +1,308 @@ # Website -Welcome to the Website repository, part of the AirQo Frontend project. This website is built with [Next.js](https://nextjs.org) and was bootstrapped using [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). The live website can be found at [airqo.net](https://airqo.net) +![codecov](https://codecov.io/gh/airqo-platform/AirQo-frontend/graph/badge.svg?token=LsBcFL42rz) ![passing](https://img.shields.io/badge/passing-816%20%E2%80%94%20100%25-brightgreen?style=flat-square) ![failing](https://img.shields.io/badge/failing-0-brightgreen?style=flat-square) -> **Note:** This repository only contains the frontend portion of the project. The backend has been built with Django and is maintained in the [airqo-api](https://github.com/airqo-platform/airqo-api) repository. If you wish to use the database for the website, please contact the project admin to obtain the necessary database URL for the frontend configuration +**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). -This guide provides clear, step-by-step instructions to help you set up your local development environment, run the website, and contribute effectively. Additionally, it explains how to handle environment variables and update CI/CD workflows. +> **Note:** This is the frontend portion of the project. The backend is built with Django and maintained in the [airqo-api](https://github.com/airqo-platform/airqo-api) repository. --- ## Table of Contents -- [Website](#website) - - [Table of Contents](#table-of-contents) - - [Getting Started](#getting-started) - - [1. Clone the Repository](#1-clone-the-repository) - - [2. Navigate to the Website Folder](#2-navigate-to-the-website-folder) - - [3. Install Dependencies](#3-install-dependencies) - - [4. Run the Development Server](#4-run-the-development-server) - - [Environment Variables \& Workflow Updates](#environment-variables--workflow-updates) - - [Backend \& Database Integration](#backend--database-integration) - - [Contributing](#contributing) - - [Learn More](#learn-more) - - [Deployment](#deployment) - - [Running with Docker](#running-with-docker) - - [Server-side Variables](#server-side-variables) - - [Client-side Variables](#client-side-variables) +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [Available Scripts](#available-scripts) +- [Environment Variables](#environment-variables) +- [Testing](#testing) +- [Running with Docker](#running-with-docker) +- [Contributing](#contributing) --- ## Getting Started -Follow these steps to set up your local development environment for the website. These instructions are applicable on Windows, Mac, and Linux. +### Prerequisites -### 1. Clone the Repository - -Clone the AirQo Frontend repository using the following command: - -```bash -git clone https://github.com/airqo-platform/AirQo-frontend.git -``` - -_Tip for Mac/Linux:_ -If you encounter any permission issues with Git, ensure that Git is correctly installed on your system. You can install Git via [Homebrew](https://brew.sh) on Mac (`brew install git`) or use your package manager on Linux (e.g., `sudo apt-get install git` on Debian/Ubuntu). - -### 2. Navigate to the Website Folder - -After cloning the repository, navigate to the folder that contains the website application: +- **Node.js 20 (LTS)** or newer +- **npm** (comes with Node.js) ```bash -cd AirQo-frontend/src/website2 +node -v # verify Node.js +npm -v # verify npm ``` -_Note:_ -The folder structure is consistent across all operating systems. Use your terminal (Mac/Linux) or Command Prompt/PowerShell (Windows) to run this command. - -### 3. Install Dependencies - -This project targets **Node.js 20 (LTS) or newer**. Ensure you have Node.js and npm installed on your system by running: +### Installation ```bash -node -v -npm -v -``` - -If you need to install or update Node.js, download it from [nodejs.org](https://nodejs.org/) or use a version manager like [nvm](https://github.com/nvm-sh/nvm) (especially useful for Mac/Linux). +# Clone the repository +git clone https://github.com/airqo-platform/AirQo-frontend.git -To install the required dependencies using **npm**, run: +# Navigate to the website folder +cd AirQo-frontend/src/website -```bash +# Install dependencies npm install -``` - -_For Mac/Linux Users:_ -If you experience permission issues while installing packages globally, consider using a Node version manager like [nvm](https://github.com/nvm-sh/nvm). - -### 4. Run the Development Server - -Launch the development server with the following command: -```bash +# Start the development server npm run dev ``` -Once the server starts, open [http://localhost:3000](http://localhost:3000) in your browser to view the website. The page will automatically reload as you make changes. - -_Alternate Tips for Mac/Linux:_ - -- Use your terminal’s tab completion to quickly navigate directories. -- If you’re using a firewall or proxy, ensure your local ports are accessible. +Open [http://localhost:3000](http://localhost:3000) in your browser. --- -## Features & Special Routes - -### Air Quality Billboard Display - -The website includes a specialized billboard display system optimized for large screens and public displays: +## Project Structure -#### Dynamic Billboard Routes - -- **Cohort Display:** - - `/billboard/cohort` - Randomly rotates through all cohorts (auto-refresh every 20s) - - `/billboard/cohort/{name}` - Displays a specific cohort location -- **Grid Display:** - - `/billboard/grid` - Randomly rotates through all grids (auto-refresh every 20s) - - `/billboard/grid/{name}` - Displays a specific grid location - -#### Billboard Features - -- ✅ Clean, professional interface (no navigation controls) -- ✅ Large, readable PM2.5 values with color-coded indicators -- ✅ Air quality icons and 7-day forecasts -- ✅ QR codes for mobile access -- ✅ Optimized for 24/7 public display - -**Examples:** - -```text -/billboard/cohort/airqo_g5429 -/billboard/grid/kampala_central ``` - -📖 **Complete Documentation:** - -- [Air Quality Billboard Guide](docs/AIR_QUALITY_BILLBOARD.md) - Complete setup, usage, and technical documentation - ---- - -## Environment Variables & Workflow Updates - -When adding new environment variables to the project, ensure that the CI/CD workflows remain consistent across staging, production, and preview environments. - -For example, if you add new variables, update your workflow YAML files as follows: - -```bash -echo " OPENCAGE_API_KEY: ${{ secrets.WEBSITE_OPENCAGE_API_KEY }}" >> .env.yaml -echo " API_TOKEN: ${{ secrets.WEBSITE_API_TOKEN }}" >> .env.yaml -echo " SLACK_WEBHOOK_URL: ${{ secrets.WEBSITE_SLACK_WEBHOOK_URL }}" >> .env.yaml -echo " SLACK_CHANNEL: ${{ secrets.WEBSITE_SLACK_CHANNEL }}" >> .env.yaml +src/ +├── app/ # Next.js 14 App Router routes +│ ├── (site)/ # Main public pages (home, about, careers, etc.) +│ ├── (content)/ # Blog content pages +│ ├── (products)/ # Product detail pages +│ ├── (solutions)/ # Solution/case-study pages +│ ├── (legal)/ # Legal and policy pages +│ ├── (programs)/ # Africa Clean Air Forum pages +│ ├── (developers)/ # Developer resources and packages +│ ├── (utility)/ # Data explorer, billboards +│ └── api/ # API proxy routes +├── components/ # Reusable React components +│ ├── ui/ # shadcn/ui primitives (button, card, dialog, etc.) +│ ├── layout/ # Navbar, Footer, MainLayout +│ ├── sections/ # Feature sections (AirQualityBillboard, etc.) +│ ├── cards/ # Card components +│ ├── dialogs/ # Modal dialogs +│ ├── feedback/ # EmptyState, ErrorState, LoadingState +│ ├── map/ # Map components (Mapbox GL) +│ ├── providers/ # AppProviders, QueryProvider, ReduxProvider +│ └── common/ # Shared components (AirQualityStatusIcon, etc.) +├── features/ # Page-level feature components +│ ├── home/ # Homepage sections +│ ├── about/ # About page +│ ├── blogs/ # Blog listing and detail +│ ├── careers/ # Job listings +│ ├── events/ # Events listing +│ ├── products/ # Product pages +│ ├── solutions/ # Solution pages (network-coverage, african-cities) +│ ├── clean-air-forum/ # Africa Clean Air Forum +│ ├── contact/ # Contact form +│ ├── faqs/ # FAQ pages +│ ├── legal/ # Legal pages (terms, privacy, cookies) +│ ├── partners/ # Partner pages +│ ├── press/ # Press page +│ ├── resources/ # Resource downloads +│ └── packages/ # Developer packages and icons +├── hooks/ # Custom React hooks +│ ├── endpointsNew.ts # TanStack Query hooks for API endpoints +│ ├── reduxHooks.ts # Typed Redux hooks (useDispatch, useSelector) +│ ├── useResourceFilter.ts +│ └── useLockBodyScroll.ts +├── lib/ # Utility functions and helpers +│ ├── utils/ # Utility modules (formatDate, slugify, airQuality, etc.) +│ ├── security/ # sanitizeHtml, safeExternalLink +│ ├── analytics/ # Google Analytics helpers +│ └── metadata/ # SEO metadata generation +├── queries/ # TanStack Query definitions +│ ├── query-keys.ts # Query key factories +│ └── *.queries.ts # Domain-specific query hooks +├── services/ # API service layer +│ ├── api/ # ApiClient, BaseApiService, error handling +│ ├── website/ # Website-specific API services (blogs, events, etc.) +│ └── external/ # External services (analytics, cloudinary, maps) +├── store/ # Redux store +│ └── slices/ # Redux slices (country, modal, forum) +├── styles/ # Global CSS (Tailwind theme, utilities) +├── types/ # TypeScript type definitions +├── configs/ # Static configuration data +└── __mocks__/ # Jest mock files (setup, fileMock, mapbox-gl, recharts) ``` -**Important Notes:** - -- Verify that the secret names used in your workflows match those defined in your repository. -- If you add new environment variables, please contact the project admin to have them added to the GitHub secrets. -- Always update the workflow files whenever you introduce or modify environment variables. -- You can find the workflow files for the different environments in the `.github/workflows` folder. - --- -## Backend & Database Integration - -The backend for this project is built with Django and is maintained in the [airqo-api](https://github.com/airqo-platform/airqo-api) repository. If you wish to connect the website to a live database: - -- **Database Access:** - You will need the database URL which is not publicly available. - **Action Required:** Contact the project admin to obtain the database URL for frontend integration. - -- **Environment Variables:** - Once you receive the database URL, update your environment variables accordingly in the `.env.yaml` file and any other relevant configuration files. +## Available Scripts + +| Command | Description | +| ----------------------- | ------------------------------------------ | +| `npm run dev` | Start development server on localhost:3000 | +| `npm run build` | Production build | +| `npm run start` | Start production server | +| `npm run lint` | Run ESLint | +| `npm run lint:fix` | Run ESLint with auto-fix | +| `npm run format` | Format code with Prettier | +| `npm test` | Run all Jest unit tests | +| `npm run test:watch` | Run tests in watch mode | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run e2e` | Run E2E tests with Selenium (headless) | +| `npm run e2e:headed` | Run E2E tests with browser visible | --- -## Contributing - -We welcome contributions from the open source community. To help you get started, please follow these steps: - -1. **Fork and Clone:** - - Fork the repository on GitHub. - - Clone your fork to your local machine: +## Environment Variables - ```bash - git clone https://github.com/your-username/AirQo-frontend.git - ``` +Copy `.env.sample` to `.env` and fill in the values. -2. **Create a Branch:** - - Create a feature or bug-fix branch: - - ```bash - git checkout -b feature/your-feature-name - ``` - -3. **Make Your Changes:** - - Follow the coding standards and guidelines established in the project. - - Update documentation as needed. - - Test your changes locally to ensure everything works as expected. - -4. **Commit and Push:** - - Commit your changes with a clear commit message: +### Server-side Variables - ```bash - git commit -m "Description of your changes" - ``` +| Variable | Description | +| -------------------------- | ---------------------------------------- | +| `API_URL` | AirQo platform API base URL | +| `API_TOKEN` | AirQo API authentication token | +| `OPENCAGE_API_KEY` | OpenCage geocoding API key | +| `SLACK_WEBHOOK_URL` | Slack webhook for error logging | +| `SLACK_CHANNEL` | Slack channel for notifications | +| `GOOGLE_SITE_VERIFICATION` | Google Search Console verification token | - - Push your branch to your fork: +### Client-side Variables - ```bash - git push origin feature/your-feature-name - ``` +| Variable | Description | +| --------------------------------- | -------------------------------------------------- | +| `NEXT_PUBLIC_SITE_URL` | Comma-separated site URLs (first = canonical base) | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics tracking ID | +| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | Mapbox access token for maps | -5. **Open a Pull Request:** - - Navigate to the main repository on GitHub and open a Pull Request against the `main` branch. - - Provide a detailed description of your changes and reference any related issues. +> **Security:** Never commit `.env` files. They are in `.gitignore`. --- -## Learn More +## Testing -To expand your knowledge about Next.js and modern web development, consider exploring these resources: +### Unit Tests (Jest) -- **[Next.js Documentation](https://nextjs.org/docs)** – Learn about Next.js features and APIs. -- **[Learn Next.js](https://nextjs.org/learn)** – An interactive tutorial for building Next.js applications. -- **[Next.js GitHub Repository](https://github.com/vercel/next.js)** – Explore and contribute to the core Next.js codebase. +816 tests across 36 test suites covering utilities, Redux, hooks, services, config, and security modules. ---- - -## Deployment - -The simplest way to deploy the website is via the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme). For more detailed deployment instructions, refer to the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying). +```bash +npm test # Run all tests +npm run test:watch # Watch mode +npm run test:coverage # Run with coverage report +npm test -- --testPathPattern=slugify # Run specific test file +``` ---- +**Test categories:** -## Running with Docker +| Category | Location | What it covers | +| ------------ | ----------------------------- | ------------------------------------------------------------------ | +| Utils | `src/lib/utils/__tests__/` | formatDate, slugify, airQuality, calendarUtils, storageUtils, etc. | +| Redux | `src/store/slices/__tests__/` | countrySlice, modalSlice, forumSlice | +| Hooks | `src/hooks/__tests__/` | useResourceFilter, useLockBodyScroll | +| API Services | `src/services/api/__tests__/` | BaseApiService, api-routes | +| Query Keys | `src/queries/__tests__/` | Query key factories, parameter normalization | +| Config | `src/config/__tests__/` | routes, navigation, packages, env, site config | +| Security | `src/lib/security/__tests__/` | sanitizeHtml, safeExternalLink | +| Lib | `src/lib/__tests__/` | siteUrl, environmentAwareUrl, logger | -This repository includes a `docker-compose.yml` that provides two services: +**Mocking strategy:** -- `web` - development service which bind-mounts the source and runs `npm run dev` with hot-reload on port 3000 (uses Node 20 image). -- `web-prod` - production-like service that builds the standalone Next.js app using the provided `Dockerfile` (Node 20) and serves it on port 8080. +| Dependency | Approach | +| -------------- | ----------------------------------------------- | +| Next.js router | `jest.mock('next/navigation')` | +| API responses | Mock `apiClient.request` via `jest.mock()` | +| localStorage | `Object.defineProperty` with mock functions | +| DOMPurify | Works in jsdom; server path tested separately | +| Mapbox GL | Manual mock in `src/__mocks__/mapbox-gl.ts` | +| Recharts | Stub components in `src/__mocks__/recharts.tsx` | +| CSS/SCSS | `identity-obj-proxy` | +| Time | `jest.useFakeTimers()` | -Usage (run these from the website folder: `d:/projects/AirQo-frontend/src/website2`): +### E2E Tests (Selenium + Mocha) -- Start dev (hot-reload, port 3000): +End-to-end tests covering all website pages using Selenium WebDriver with Mocha/Chai and Page Object Model pattern. ```bash -docker compose up web -# or detached -docker compose up -d web -``` - -- Build and run production-like container (port 8080): +# Start the dev server first +npm run dev -```bash -docker compose up --build web-prod -# or detached -docker compose up -d --build web-prod +# In another terminal +npm run e2e # headless mode +npm run e2e:headed # with browser visible ``` -- Stop and remove containers (and anonymous volumes): +**E2E structure:** -```bash -docker compose down -v +``` +e2e/ +├── tests/ +│ ├── navigation/ # Navbar, footer, page transitions +│ │ ├── navbar.test.ts +│ │ ├── footer.test.ts +│ │ └── page-transitions.test.ts +│ └── pages/ # Page-specific tests +│ ├── home-page.test.ts +│ ├── products-page.test.ts +│ ├── solutions-page.test.ts +│ ├── blogs-page.test.ts +│ ├── legal-page.test.ts +│ ├── contact-page.test.ts +│ ├── about-page.test.ts +│ ├── careers-page.test.ts +│ └── events-page.test.ts +├── pages/ # Page Object Model classes +│ ├── base.page.ts # BasePage with common methods +│ ├── navbar.page.ts +│ ├── footer.page.ts +│ ├── home.page.ts +│ ├── products.page.ts +│ ├── solutions.page.ts +│ ├── blogs.page.ts +│ ├── legal.page.ts +│ ├── contact.page.ts +│ ├── about.page.ts +│ ├── careers.page.ts +│ └── events.page.ts +├── setup.ts # WebDriver setup/teardown +├── config.ts # Configuration (BASE_URL, BROWSER, HEADLESS) +├── tsconfig.json # TypeScript config for E2E +└── .mocharc.yml # Mocha configuration ``` -- Clean/rebuild the production image: +**Configuration:** Create `e2e/.env` (copy from `e2e/.env.example`): ```bash -docker compose build --no-cache web-prod +BASE_URL=http://localhost:3000 +BROWSER=chrome +HEADLESS=true ``` -Environment variables and build args +### Coverage -**SECURITY UPDATE:** The project has been refactored to use server-side environment variables for sensitive data, improving security by not exposing credentials to the client. +Coverage reports are uploaded to Codecov in CI with the `website` flag. -### Server-side Variables - -- API_URL (AirQo platform API base URL) -- API_TOKEN (AirQo API authentication token) -- OPENCAGE_API_KEY (OpenCage geocoding API key) -- SLACK_WEBHOOK_URL (Slack webhook for logging) -- SLACK_CHANNEL (Slack channel for notifications) -- GOOGLE_SITE_VERIFICATION (Google Search Console verification token) - -### Client-side Variables - -- NEXT_PUBLIC_SITE_URL (comma-separated site URLs; the first entry is the canonical base) -- NEXT_PUBLIC_GA_MEASUREMENT_ID (Google Analytics tracking ID) -- NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN (Mapbox access token) +```bash +npm run test:coverage +# Open coverage/index.html in your browser to view the report +``` -How to provide them: +--- -- Preferred: use the `.env` file in the project root. The `web` and `web-prod` services load `.env` via `env_file` in `docker-compose.yml`. +## Running with Docker ```bash -# Edit .env and fill in the values (do NOT commit secrets) -# The .env file is already in .gitignore -``` +# Development (hot-reload, port 3000) +docker compose up web -- Alternative: pass build args on the command line during build: +# Production-like (port 8080) +docker compose up --build web-prod -```bash -docker compose build --build-arg API_URL="https://api.example.com" web-prod +# Stop and remove containers +docker compose down -v ``` -Notes and safety +Ensure you have a `.env` file in the website directory before building. -- The Dockerfile uses Node 20 (LTS) and is optimized for production with minimal layers -- Do NOT commit files that contain secrets (`.env` is already in `.gitignore`) -- Docker Compose (v2) prefers the `docker compose` subcommand (without a hyphen) -- Do NOT commit files that contain secrets (for example `.env`, `.env.compose`). The repository now also includes `.env` in `.gitignore` for extra safety. -- Docker Compose (v2) prefers the `docker compose` subcommand (without a hyphen). Depending on your setup you may still have `docker-compose` available; both may work but `docker compose` is the recommended form for Compose v2. +--- -Troubleshooting +## Contributing -- If the browser cannot reach the site but `curl` shows responses, try `http://127.0.0.1:8080` instead of `http://localhost:8080` (IPv4 vs IPv6 / proxy differences can cause issues). -- If a build inside Docker fails, first ensure the app builds locally with `npm ci` and `npm run build` and check the logs: +1. Fork and clone the repository +2. Create a feature branch: `git checkout -b feature/your-feature-name` +3. Make your changes following the existing code style +4. Run `npm run lint:fix && npm run format` before committing +5. Run `npm test` to ensure all tests pass +6. Push and open a Pull Request against `staging` -```bash -docker compose logs web-prod --tail 200 -``` - -If you need to debug the build environment, you can run an interactive build container and inspect environment variables before running `npm run build`. +--- -Thank you for your interest in contributing to our website. Your support and contributions are vital to the ongoing success and improvement of the project! +## Learn More ---- +- [Next.js Documentation](https://nextjs.org/docs) +- [Learn Next.js](https://nextjs.org/learn) +- [AirQo API Repository](https://github.com/airqo-platform/airqo-api) diff --git a/src/website/e2e/.env.example b/src/website/e2e/.env.example new file mode 100644 index 0000000000..9c9e936ce1 --- /dev/null +++ b/src/website/e2e/.env.example @@ -0,0 +1,7 @@ +# E2E Testing Configuration +BASE_URL=http://localhost:3000 +BROWSER=chrome +HEADLESS=true +IMPLICIT_WAIT=10000 +PAGE_LOAD_TIMEOUT=30000 +SCREENSHOT_DIR=./e2e/screenshots diff --git a/src/website/e2e/.mocharc.yml b/src/website/e2e/.mocharc.yml new file mode 100644 index 0000000000..b10c0dd0fe --- /dev/null +++ b/src/website/e2e/.mocharc.yml @@ -0,0 +1,10 @@ +spec: 'e2e/tests/**/*.test.ts' +timeout: 60000 +reporter: mochawesome +require: + - ts-node/register/transpile-only +reporter-options: + - reportDir: ./e2e/reports + - reportFilename: e2e-report + - html: true + - json: true diff --git a/src/website/e2e/config.ts b/src/website/e2e/config.ts new file mode 100644 index 0000000000..7cdd021be0 --- /dev/null +++ b/src/website/e2e/config.ts @@ -0,0 +1,14 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +export const config = { + BASE_URL: process.env.BASE_URL || 'http://localhost:3000', + BROWSER: process.env.BROWSER || 'chrome', + HEADLESS: process.env.HEADLESS !== 'false', + IMPLICIT_WAIT: parseInt(process.env.IMPLICIT_WAIT || '10000', 10), + PAGE_LOAD_TIMEOUT: parseInt(process.env.PAGE_LOAD_TIMEOUT || '30000', 10), + SCREENSHOT_DIR: + process.env.SCREENSHOT_DIR || path.resolve(__dirname, './screenshots'), +}; diff --git a/src/website/e2e/pages/about.page.ts b/src/website/e2e/pages/about.page.ts new file mode 100644 index 0000000000..d57dfc8c9c --- /dev/null +++ b/src/website/e2e/pages/about.page.ts @@ -0,0 +1,27 @@ +import { WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class AboutPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/about-us'); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/about-us'); + } + + async isTeamSectionDisplayed(): Promise { + const text = await this.driver.getPageSource(); + return ( + text.includes('team') || + text.includes('Team') || + text.includes('about') || + text.includes('About') + ); + } +} diff --git a/src/website/e2e/pages/base.page.ts b/src/website/e2e/pages/base.page.ts new file mode 100644 index 0000000000..fab36666ba --- /dev/null +++ b/src/website/e2e/pages/base.page.ts @@ -0,0 +1,68 @@ +import { By, WebDriver } from 'selenium-webdriver'; +import { config } from '../config'; +import fs from 'fs'; +import path from 'path'; + +export class BasePage { + protected driver: WebDriver; + + constructor(driver: WebDriver) { + this.driver = driver; + } + + async navigateTo(path: string): Promise { + await this.driver.get(`${config.BASE_URL}${path}`); + } + + async find(selector: string) { + return await this.driver.findElement(By.css(selector)); + } + + async click(selector: string): Promise { + const element = await this.find(selector); + await element.click(); + } + + async typeText(selector: string, text: string): Promise { + const element = await this.find(selector); + await element.sendKeys(text); + } + + async isDisplayed(selector: string): Promise { + try { + const element = await this.find(selector); + return await element.isDisplayed(); + } catch { + return false; + } + } + + async waitForUrlContains( + text: string, + timeout: number = 10000, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const currentUrl = await this.driver.getCurrentUrl(); + if (currentUrl.includes(text)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + } + + async takeScreenshot(name: string): Promise { + const screenshotDir = config.SCREENSHOT_DIR; + if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); + } + + const screenshotPath = path.join( + screenshotDir, + `${name}-${Date.now()}.png`, + ); + const screenshot = await this.driver.takeScreenshot(); + fs.writeFileSync(screenshotPath, screenshot, 'base64'); + } +} diff --git a/src/website/e2e/pages/blogs.page.ts b/src/website/e2e/pages/blogs.page.ts new file mode 100644 index 0000000000..8e494bb8c1 --- /dev/null +++ b/src/website/e2e/pages/blogs.page.ts @@ -0,0 +1,32 @@ +import { By, WebDriver, WebElement } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class BlogsPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/blogs'); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/blogs'); + } + + async getBlogPosts(): Promise { + return await this.driver.findElements( + By.css( + 'article, [data-testid="blog-post"], .blog-card, a[href*="/blogs/"]', + ), + ); + } + + async clickFirstBlogPost(): Promise { + const el = await this.driver.findElement( + By.css('article a[href*="/blogs/"], a[href*="/blogs/"]'), + ); + await el.click(); + } +} diff --git a/src/website/e2e/pages/careers.page.ts b/src/website/e2e/pages/careers.page.ts new file mode 100644 index 0000000000..28256582fe --- /dev/null +++ b/src/website/e2e/pages/careers.page.ts @@ -0,0 +1,23 @@ +import { By, WebDriver, WebElement } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class CareersPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/careers'); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/careers'); + } + + async getJobListings(): Promise { + return await this.driver.findElements( + By.css('a[href*="/careers/"], [data-testid="job-listing"], .job-card'), + ); + } +} diff --git a/src/website/e2e/pages/contact.page.ts b/src/website/e2e/pages/contact.page.ts new file mode 100644 index 0000000000..9bc1cf05ca --- /dev/null +++ b/src/website/e2e/pages/contact.page.ts @@ -0,0 +1,49 @@ +import { WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class ContactPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/contact'); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/contact'); + } + + async isFormDisplayed(): Promise { + return ( + (await this.isDisplayed('button')) || (await this.isDisplayed('form')) + ); + } + + async fillForm(data: { + name?: string; + email?: string; + message?: string; + }): Promise { + if (data.name) { + await this.typeText( + 'input[name="name"], input[placeholder*="name" i]', + data.name, + ); + } + if (data.email) { + await this.typeText( + 'input[name="email"], input[type="email"]', + data.email, + ); + } + if (data.message) { + await this.typeText('textarea, input[name="message"]', data.message); + } + } + + async submitForm(): Promise { + await this.click('button[type="submit"], form button'); + } +} diff --git a/src/website/e2e/pages/events.page.ts b/src/website/e2e/pages/events.page.ts new file mode 100644 index 0000000000..48a8217a83 --- /dev/null +++ b/src/website/e2e/pages/events.page.ts @@ -0,0 +1,23 @@ +import { By, WebDriver, WebElement } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class EventsPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/events'); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/events'); + } + + async getEventCards(): Promise { + return await this.driver.findElements( + By.css('a[href*="/events/"], [data-testid="event-card"], .event-card'), + ); + } +} diff --git a/src/website/e2e/pages/footer.page.ts b/src/website/e2e/pages/footer.page.ts new file mode 100644 index 0000000000..377e6ca556 --- /dev/null +++ b/src/website/e2e/pages/footer.page.ts @@ -0,0 +1,29 @@ +import { By, WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class FooterPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async isFooterDisplayed(): Promise { + return await this.isDisplayed('footer#WebsiteFooter, footer'); + } + + async getFooterLinks(): Promise { + const elements = await this.driver.findElements(By.css('footer a[href]')); + const hrefs: string[] = []; + for (const el of elements) { + const href = await el.getAttribute('href'); + if (href) hrefs.push(href); + } + return hrefs; + } + + async clickFooterLink(label: string): Promise { + const el = await this.driver.findElement( + By.xpath(`//footer//a[contains(text(),'${label}')]`), + ); + await el.click(); + } +} diff --git a/src/website/e2e/pages/home.page.ts b/src/website/e2e/pages/home.page.ts new file mode 100644 index 0000000000..ef5a9760e3 --- /dev/null +++ b/src/website/e2e/pages/home.page.ts @@ -0,0 +1,32 @@ +import { WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class HomePage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(): Promise { + await this.navigateTo('/home'); + } + + async isHeroSectionDisplayed(): Promise { + return await this.isDisplayed('[data-testid="hero-section"]'); + } + + async isNavbarDisplayed(): Promise { + return await this.isDisplayed('nav'); + } + + async isFooterDisplayed(): Promise { + return await this.isDisplayed('footer'); + } + + async clickGetStartedButton(): Promise { + await this.click('[data-testid="get-started-button"]'); + } + + async getPageTitle(): Promise { + return await this.driver.getTitle(); + } +} diff --git a/src/website/e2e/pages/legal.page.ts b/src/website/e2e/pages/legal.page.ts new file mode 100644 index 0000000000..88792f45de --- /dev/null +++ b/src/website/e2e/pages/legal.page.ts @@ -0,0 +1,38 @@ +import { By, WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class LegalPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(legalSlug: string): Promise { + await this.navigateTo(`/legal/${legalSlug}`); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/legal/'); + } + + async getTabLinks(): Promise { + const elements = await this.driver.findElements( + By.css('div.flex.border-b button'), + ); + const labels: string[] = []; + for (const el of elements) { + const text = await el.getText(); + if (text) labels.push(text.trim()); + } + return labels; + } + + async clickTab(label: string): Promise { + const el = await this.driver.findElement( + By.xpath( + `//div[contains(@class,'border-b')]//button[contains(text(),'${label}')]`, + ), + ); + await el.click(); + } +} diff --git a/src/website/e2e/pages/navbar.page.ts b/src/website/e2e/pages/navbar.page.ts new file mode 100644 index 0000000000..2325746d02 --- /dev/null +++ b/src/website/e2e/pages/navbar.page.ts @@ -0,0 +1,41 @@ +import { By, WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class NavbarPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async isNavbarDisplayed(): Promise { + return await this.isDisplayed('nav'); + } + + async isLogoDisplayed(): Promise { + return await this.isDisplayed('nav a[href="/home"] img'); + } + + async getNavLinks(): Promise { + const elements = await this.driver.findElements( + By.css('nav .hidden.md\\:flex a, nav .hidden.md\\:flex button'), + ); + const hrefs: string[] = []; + for (const el of elements) { + const href = await el.getAttribute('href'); + if (href) hrefs.push(href); + } + return hrefs; + } + + async clickNavLink(label: string): Promise { + const el = await this.driver.findElement( + By.xpath( + `//nav//div[contains(@class,'hidden') and contains(@class,'md:flex')]//button[contains(.,'${label}')]`, + ), + ); + await el.click(); + } + + async clickLogo(): Promise { + await this.click('nav a[href="/home"]'); + } +} diff --git a/src/website/e2e/pages/products.page.ts b/src/website/e2e/pages/products.page.ts new file mode 100644 index 0000000000..2cd6817571 --- /dev/null +++ b/src/website/e2e/pages/products.page.ts @@ -0,0 +1,21 @@ +import { WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class ProductsPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(productSlug: string): Promise { + await this.navigateTo(`/products/${productSlug}`); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/products/'); + } + + async getPageTitle(): Promise { + return await this.driver.getTitle(); + } +} diff --git a/src/website/e2e/pages/solutions.page.ts b/src/website/e2e/pages/solutions.page.ts new file mode 100644 index 0000000000..7e1b8d45e0 --- /dev/null +++ b/src/website/e2e/pages/solutions.page.ts @@ -0,0 +1,17 @@ +import { WebDriver } from 'selenium-webdriver'; +import { BasePage } from './base.page'; + +export class SolutionsPage extends BasePage { + constructor(driver: WebDriver) { + super(driver); + } + + async open(solutionSlug: string): Promise { + await this.navigateTo(`/solutions/${solutionSlug}`); + } + + async isPageLoaded(): Promise { + const url = await this.driver.getCurrentUrl(); + return url.includes('/solutions/'); + } +} diff --git a/src/website/e2e/setup.ts b/src/website/e2e/setup.ts new file mode 100644 index 0000000000..97ffb401cd --- /dev/null +++ b/src/website/e2e/setup.ts @@ -0,0 +1,57 @@ +import { Builder, By, WebDriver } from 'selenium-webdriver'; +import { config } from './config'; +import fs from 'fs'; +import path from 'path'; + +let driver: WebDriver; + +export async function createDriver(): Promise { + const options = new (require('selenium-webdriver/chrome').Options)(); + + if (config.HEADLESS) { + options.addArguments('--headless'); + } + + options.addArguments('--no-sandbox'); + options.addArguments('--disable-dev-shm-usage'); + options.addArguments('--disable-gpu'); + + driver = await new Builder() + .forBrowser(config.BROWSER) + .setChromeOptions(options) + .build(); + + await driver.manage().setTimeouts({ + implicit: config.IMPLICIT_WAIT, + pageLoad: config.PAGE_LOAD_TIMEOUT, + }); + + return driver; +} + +export async function quitDriver(): Promise { + if (driver) { + await driver.quit(); + } +} + +export async function screenshotOnFailure(this: any, test: any) { + if (test.state === 'failed') { + const screenshotDir = config.SCREENSHOT_DIR; + if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); + } + + const screenshotPath = path.join( + screenshotDir, + `${test.title}-${Date.now()}.png`, + ); + const screenshot = await driver.takeScreenshot(); + fs.writeFileSync(screenshotPath, screenshot, 'base64'); + console.log(`Screenshot saved: ${screenshotPath}`); + } +} + +export function getDriver(): WebDriver { + return driver; +} diff --git a/src/website/e2e/tests/navigation/footer.test.ts b/src/website/e2e/tests/navigation/footer.test.ts new file mode 100644 index 0000000000..321d02e750 --- /dev/null +++ b/src/website/e2e/tests/navigation/footer.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { FooterPage } from '../../pages/footer.page'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Footer', function () { + let footer: FooterPage; + + beforeEach(async function () { + const driver = await createDriver(); + footer = new FooterPage(driver); + await footer.navigateTo('/home'); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('should display the footer on home page', async function () { + const displayed = await footer.isFooterDisplayed(); + expect(displayed).to.be.true; + }); + + it('should contain product links', async function () { + const links = await footer.getFooterLinks(); + const productLinks = links.filter((l) => l.includes('/products/')); + expect(productLinks).to.not.be.empty; + }); + + it('should contain solution links', async function () { + const links = await footer.getFooterLinks(); + const solutionLinks = links.filter((l) => l.includes('/solutions/')); + expect(solutionLinks).to.not.be.empty; + }); + + it('should contain company/about links', async function () { + const links = await footer.getFooterLinks(); + const aboutLinks = links.filter( + (l) => l.includes('/about-us') || l.includes('/careers'), + ); + expect(aboutLinks).to.not.be.empty; + }); + + it('should not display footer on /contact page', async function () { + const base = new BasePage(footer.driver); + await base.navigateTo('/contact'); + const displayed = await footer.isFooterDisplayed(); + expect(displayed).to.be.false; + }); +}); diff --git a/src/website/e2e/tests/navigation/header-navigation.test.ts b/src/website/e2e/tests/navigation/header-navigation.test.ts new file mode 100644 index 0000000000..af6e736034 --- /dev/null +++ b/src/website/e2e/tests/navigation/header-navigation.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Header Navigation', function () { + let basePage: BasePage; + + beforeEach(async function () { + const driver = await createDriver(); + basePage = new BasePage(driver); + await basePage.navigateTo('/home'); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('should display the navigation bar', async function () { + const isDisplayed = await basePage.isDisplayed('nav'); + expect(isDisplayed).to.be.true; + }); + + it('should have logo link', async function () { + const isLogoDisplayed = await basePage.isDisplayed('nav a[href="/home"]'); + expect(isLogoDisplayed).to.be.true; + }); + + it('should have Home link', async function () { + const isHomeLinkDisplayed = await basePage.isDisplayed('a[href="/home"]'); + expect(isHomeLinkDisplayed).to.be.true; + }); + + it('should have About link', async function () { + const isAboutLinkDisplayed = await basePage.isDisplayed('a[href="/about"]'); + expect(isAboutLinkDisplayed).to.be.true; + }); + + it('should have Products link', async function () { + const isProductsLinkDisplayed = await basePage.isDisplayed( + 'a[href="/products"]', + ); + expect(isProductsLinkDisplayed).to.be.true; + }); + + it('should navigate to Home when clicking Home link', async function () { + await basePage.click('a[href="/home"]'); + const urlContainsHome = await basePage.waitForUrlContains('/home'); + expect(urlContainsHome).to.be.true; + }); + + it('should navigate to About when clicking About link', async function () { + await basePage.click('a[href="/about"]'); + const urlContainsAbout = await basePage.waitForUrlContains('/about'); + expect(urlContainsAbout).to.be.true; + }); +}); diff --git a/src/website/e2e/tests/navigation/navbar.test.ts b/src/website/e2e/tests/navigation/navbar.test.ts new file mode 100644 index 0000000000..1061a77182 --- /dev/null +++ b/src/website/e2e/tests/navigation/navbar.test.ts @@ -0,0 +1,85 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { NavbarPage } from '../../pages/navbar.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Navbar', function () { + let navbar: NavbarPage; + + beforeEach(async function () { + const driver = await createDriver(); + navbar = new NavbarPage(driver); + await navbar.navigateTo('/home'); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('should display the navbar on home page', async function () { + const displayed = await navbar.isNavbarDisplayed(); + expect(displayed).to.be.true; + }); + + it('should display the logo', async function () { + const displayed = await navbar.isLogoDisplayed(); + expect(displayed).to.be.true; + }); + + it('should have logo linking to /home', async function () { + const logoLink = await navbar.find('nav a[href="/home"]'); + const href = await logoLink.getAttribute('href'); + expect(href).to.include('/home'); + }); + + it('should have main nav dropdown menus', async function () { + const dropdowns = await navbar.driver.findElements( + By.css('nav .hidden.md\\:flex button'), + ); + const labels: string[] = []; + for (const btn of dropdowns) { + const text = await btn.getText(); + if (text) labels.push(text.trim()); + } + expect(labels).to.include('Products'); + expect(labels).to.include('Solutions'); + expect(labels).to.include('About'); + expect(labels).to.include('Developers'); + }); + + it('should have Blogs link', async function () { + const blogsLink = await navbar.find('nav a[href="/blogs"]'); + expect(blogsLink).to.not.be.null; + }); + + it('should navigate to /products when clicking Products', async function () { + await navbar.clickNavLink('Products'); + const url = await navbar.driver.getCurrentUrl(); + expect(url).to.include('/products'); + }); + + it('should navigate to /solutions when clicking Solutions', async function () { + await navbar.clickNavLink('Solutions'); + const url = await navbar.driver.getCurrentUrl(); + expect(url).to.include('/solutions'); + }); + + it('should navigate to /about-us when clicking About', async function () { + await navbar.clickNavLink('About'); + const url = await navbar.driver.getCurrentUrl(); + expect(url).to.include('/about'); + }); + + it('should have navbar on products page', async function () { + await navbar.navigateTo('/products/vertex'); + const displayed = await navbar.isNavbarDisplayed(); + expect(displayed).to.be.true; + }); + + it('should have navbar on about-us page', async function () { + await navbar.navigateTo('/about-us'); + const displayed = await navbar.isNavbarDisplayed(); + expect(displayed).to.be.true; + }); +}); diff --git a/src/website/e2e/tests/navigation/page-transitions.test.ts b/src/website/e2e/tests/navigation/page-transitions.test.ts new file mode 100644 index 0000000000..460698a552 --- /dev/null +++ b/src/website/e2e/tests/navigation/page-transitions.test.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Page Transitions', function () { + let page: BasePage; + + beforeEach(async function () { + const driver = await createDriver(); + page = new BasePage(driver); + await page.navigateTo('/home'); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('should navigate from home to products and back', async function () { + const homeUrl = await page.driver.getCurrentUrl(); + expect(homeUrl).to.include('/home'); + + await page.navigateTo('/products'); + const url2 = await page.driver.getCurrentUrl(); + expect(url2).to.include('/products'); + + await page.driver.navigate().back(); + await page.driver.sleep(500); + const url3 = await page.driver.getCurrentUrl(); + expect(url3).to.include('/home'); + }); + + it('should navigate from home to about and back', async function () { + await page.navigateTo('/about-us'); + const url1 = await page.driver.getCurrentUrl(); + expect(url1).to.include('/about-us'); + + await page.driver.navigate().back(); + await page.driver.sleep(500); + const url2 = await page.driver.getCurrentUrl(); + expect(url2).to.not.include('/about-us'); + }); + + it('browser back button should work', async function () { + await page.navigateTo('/products/vertex'); + const url1 = await page.driver.getCurrentUrl(); + expect(url1).to.include('/products/vertex'); + + await page.driver.navigate().back(); + await page.driver.sleep(500); + const url2 = await page.driver.getCurrentUrl(); + expect(url2).to.not.include('/products/vertex'); + }); + + it('URL should update correctly on navigation', async function () { + await page.navigateTo('/blogs'); + const url = await page.driver.getCurrentUrl(); + expect(url).to.include('/blogs'); + }); +}); diff --git a/src/website/e2e/tests/pages/about-page.test.ts b/src/website/e2e/tests/pages/about-page.test.ts new file mode 100644 index 0000000000..f8789b754e --- /dev/null +++ b/src/website/e2e/tests/pages/about-page.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { AboutPage } from '../../pages/about.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('About Page', function () { + let aboutPage: AboutPage; + + beforeEach(async function () { + const driver = await createDriver(); + aboutPage = new AboutPage(driver); + await aboutPage.open(); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('/about-us should load successfully', async function () { + const loaded = await aboutPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + + it('should have content sections', async function () { + const hasContent = await aboutPage.isTeamSectionDisplayed(); + expect(hasContent).to.be.true; + }); + + it('should display navigation bar', async function () { + const displayed = await aboutPage.isDisplayed('nav'); + expect(displayed).to.be.true; + }); +}); diff --git a/src/website/e2e/tests/pages/blogs-page.test.ts b/src/website/e2e/tests/pages/blogs-page.test.ts new file mode 100644 index 0000000000..fe0cd36e7b --- /dev/null +++ b/src/website/e2e/tests/pages/blogs-page.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { BlogsPage } from '../../pages/blogs.page'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Blogs Page', function () { + let blogsPage: BlogsPage; + + beforeEach(async function () { + const driver = await createDriver(); + blogsPage = new BlogsPage(driver); + await blogsPage.open(); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('/blogs should load successfully', async function () { + const loaded = await blogsPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + + it('should have page title', async function () { + const title = await blogsPage.driver.getTitle(); + expect(title).to.not.be.empty; + }); + + it('should have navigation bar', async function () { + const displayed = await blogsPage.isDisplayed('nav'); + expect(displayed).to.be.true; + }); +}); diff --git a/src/website/e2e/tests/pages/careers-page.test.ts b/src/website/e2e/tests/pages/careers-page.test.ts new file mode 100644 index 0000000000..6d34998a17 --- /dev/null +++ b/src/website/e2e/tests/pages/careers-page.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { CareersPage } from '../../pages/careers.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Careers Page', function () { + let careersPage: CareersPage; + + beforeEach(async function () { + const driver = await createDriver(); + careersPage = new CareersPage(driver); + await careersPage.open(); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('/careers should load successfully', async function () { + const loaded = await careersPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + + it('should display navigation bar', async function () { + const displayed = await careersPage.isDisplayed('nav'); + expect(displayed).to.be.true; + }); + + it('should display job listings section', async function () { + const listings = await careersPage.getJobListings(); + expect(listings).to.be.an('array'); + }); +}); diff --git a/src/website/e2e/tests/pages/contact-page.test.ts b/src/website/e2e/tests/pages/contact-page.test.ts new file mode 100644 index 0000000000..d079f69446 --- /dev/null +++ b/src/website/e2e/tests/pages/contact-page.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { ContactPage } from '../../pages/contact.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Contact Page', function () { + let contactPage: ContactPage; + + beforeEach(async function () { + const driver = await createDriver(); + contactPage = new ContactPage(driver); + await contactPage.open(); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('/contact should load successfully', async function () { + const loaded = await contactPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + + it('should display contact options', async function () { + const displayed = await contactPage.isFormDisplayed(); + expect(displayed).to.be.true; + }); + + it('should display navigation bar', async function () { + const displayed = await contactPage.isDisplayed('nav'); + expect(displayed).to.be.true; + }); + + it('should not display footer', async function () { + const displayed = await contactPage.isDisplayed('footer'); + expect(displayed).to.be.false; + }); +}); diff --git a/src/website/e2e/tests/pages/events-page.test.ts b/src/website/e2e/tests/pages/events-page.test.ts new file mode 100644 index 0000000000..6073a097d7 --- /dev/null +++ b/src/website/e2e/tests/pages/events-page.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { EventsPage } from '../../pages/events.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Events Page', function () { + let eventsPage: EventsPage; + + beforeEach(async function () { + const driver = await createDriver(); + eventsPage = new EventsPage(driver); + await eventsPage.open(); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('/events should load successfully', async function () { + const loaded = await eventsPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + + it('should display navigation bar', async function () { + const displayed = await eventsPage.isDisplayed('nav'); + expect(displayed).to.be.true; + }); + + it('should display event cards section', async function () { + const cards = await eventsPage.getEventCards(); + expect(cards).to.be.an('array'); + }); +}); diff --git a/src/website/e2e/tests/pages/home-page.test.ts b/src/website/e2e/tests/pages/home-page.test.ts new file mode 100644 index 0000000000..c835305e49 --- /dev/null +++ b/src/website/e2e/tests/pages/home-page.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Home Page', function () { + let page: BasePage; + + beforeEach(async function () { + const driver = await createDriver(); + page = new BasePage(driver); + await page.navigateTo('/home'); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + it('should load with correct title containing AirQo', async function () { + const title = await page.driver.getTitle(); + expect(title).to.include('AirQo'); + }); + + it('should display the navbar', async function () { + const displayed = await page.isDisplayed('nav'); + expect(displayed).to.be.true; + }); + + it('should display the footer', async function () { + const displayed = await page.isDisplayed('footer'); + expect(displayed).to.be.true; + }); + + it('should have hero section visible', async function () { + const displayed = await page.isDisplayed('[data-testid="hero-section"]'); + expect(displayed).to.be.true; + }); + + it('should have a meta description', async function () { + const metaDesc = await page.driver.executeScript( + 'return document.querySelector("meta[name=\\"description\\"]")?.getAttribute("content")', + ); + expect(metaDesc).to.be.a('string').and.to.have.length.greaterThan(0); + }); +}); diff --git a/src/website/e2e/tests/pages/legal-page.test.ts b/src/website/e2e/tests/pages/legal-page.test.ts new file mode 100644 index 0000000000..9ba744adea --- /dev/null +++ b/src/website/e2e/tests/pages/legal-page.test.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { LegalPage } from '../../pages/legal.page'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Legal Pages', function () { + let legalPage: LegalPage; + + const legalSlugs = ['terms-of-service', 'privacy-policy', 'cookies']; + + beforeEach(async function () { + const driver = await createDriver(); + legalPage = new LegalPage(driver); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + for (const slug of legalSlugs) { + it(`/legal/${slug} should load successfully`, async function () { + await legalPage.open(slug); + const loaded = await legalPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + } + + it('should display legal tabs', async function () { + await legalPage.open('terms-of-service'); + const tabs = await legalPage.getTabLinks(); + expect(tabs).to.include('Terms of Service'); + expect(tabs).to.include('Privacy Policy'); + expect(tabs).to.include('Cookies Policy'); + }); + + it('should navigate between tabs', async function () { + await legalPage.open('terms-of-service'); + await legalPage.clickTab('Privacy Policy'); + const url = await legalPage.driver.getCurrentUrl(); + expect(url).to.include('/legal/privacy-policy'); + }); +}); diff --git a/src/website/e2e/tests/pages/products-page.test.ts b/src/website/e2e/tests/pages/products-page.test.ts new file mode 100644 index 0000000000..47dc6fc663 --- /dev/null +++ b/src/website/e2e/tests/pages/products-page.test.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { ProductsPage } from '../../pages/products.page'; +import { BasePage } from '../../pages/base.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Products Pages', function () { + let productsPage: ProductsPage; + let basePage: BasePage; + + const productSlugs = ['vertex', 'monitor', 'analytics', 'api']; + + beforeEach(async function () { + const driver = await createDriver(); + productsPage = new ProductsPage(driver); + basePage = new BasePage(driver); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + for (const slug of productSlugs) { + it(`/products/${slug} should load successfully`, async function () { + await productsPage.open(slug); + const loaded = await productsPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + } + + it('each product page should have a title', async function () { + await productsPage.open('vertex'); + const title = await productsPage.getPageTitle(); + expect(title).to.not.be.empty; + }); + + it('should navigate back to products listing from product page', async function () { + await productsPage.open('vertex'); + const url = await basePage.driver.getCurrentUrl(); + expect(url).to.include('/products/vertex'); + }); +}); diff --git a/src/website/e2e/tests/pages/solutions-page.test.ts b/src/website/e2e/tests/pages/solutions-page.test.ts new file mode 100644 index 0000000000..7d202cbd5d --- /dev/null +++ b/src/website/e2e/tests/pages/solutions-page.test.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import { SolutionsPage } from '../../pages/solutions.page'; +import { createDriver, quitDriver, screenshotOnFailure } from '../../setup'; + +describe('Solutions Pages', function () { + let solutionsPage: SolutionsPage; + + const solutionSlugs = ['communities', 'african-cities', 'research']; + + beforeEach(async function () { + const driver = await createDriver(); + solutionsPage = new SolutionsPage(driver); + }); + + afterEach(async function () { + await screenshotOnFailure.call(this, this.currentTest); + await quitDriver(); + }); + + for (const slug of solutionSlugs) { + it(`/solutions/${slug} should load successfully`, async function () { + await solutionsPage.open(slug); + const loaded = await solutionsPage.isPageLoaded(); + expect(loaded).to.be.true; + }); + } +}); diff --git a/src/website/e2e/tsconfig.json b/src/website/e2e/tsconfig.json new file mode 100644 index 0000000000..5d4c4ea7d9 --- /dev/null +++ b/src/website/e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "types": ["mocha", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/website/jest.config.js b/src/website/jest.config.js index f799e942af..60491569bf 100644 --- a/src/website/jest.config.js +++ b/src/website/jest.config.js @@ -1,18 +1,72 @@ -// jest.config.js +/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest/presets/js-with-ts-esm', testEnvironment: 'jsdom', - setupFilesAfterEnv: ['/jest.setup.ts'], + setupFilesAfterEnv: ['/src/__mocks__/setup.ts'], moduleNameMapper: { - '\\.(css|scss|sass)$': 'identity-obj-proxy', '^@/(.*)$': '/src/$1', '^@/components/(.*)$': '/src/components/$1', - '^@/pages/(.*)$': '/src/pages/$1', - // Add other aliases as needed + '^@/features/(.*)$': '/src/features/$1', + '\\.(css|scss|sass)$': 'identity-obj-proxy', + '\\.(jpg|jpeg|png|gif|webp|avif|ico|bmp|svg)$': + '/src/__mocks__/fileMock.ts', + '^mapbox-gl$': '/src/__mocks__/mapbox-gl.ts', + '^recharts$': '/src/__mocks__/recharts.tsx', }, - testPathIgnorePatterns: ['/.next/', '/node_modules/'], - modulePathIgnorePatterns: ['/.next/standalone/'], + testMatch: [ + '/src/**/__tests__/**/*.{ts,tsx}', + '/src/**/*.{spec,test}.{ts,tsx}', + ], + coverageProvider: 'v8', + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'clover'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/*.stories.{ts,tsx}', + '!src/__mocks__/**', + '!src/types/**', + '!src/app/**', + '!src/features/**', + '!src/components/**', + '!src/styles/**', + '!src/lib/metadata/**', + '!src/lib/analytics/**', + '!src/lib/devcon.ts', + '!src/lib/navItems.ts', + '!src/lib/metadata.ts', + '!src/services/external/**', + '!src/services/website/**', + '!src/hooks/endpointsNew.ts', + '!src/hooks/index.ts', + '!src/hooks/useApiHooks.tsx', + '!src/queries/index.ts', + '!src/queries/query-client.ts', + '!src/services/api/index.ts', + '!src/services/api/api-error.ts', + '!src/services/api/api-response.ts', + '!src/store/index.ts', + '!src/config/env.config.ts', + '!src/config/images.config.ts', + '!src/config/seo.config.ts', + '!src/config/site.config.ts', + '!src/lib/utils/googleTranslate.ts', + '!src/lib/utils/index.ts', + ], transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', + '^.+\\.(ts|tsx)$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + jsx: 'react-jsx', + }, + ], }, + testPathIgnorePatterns: [ + '/node_modules/', + '/.next/', + '/e2e/', + '/coverage/', + ], + modulePathIgnorePatterns: ['/.next/standalone/'], }; diff --git a/src/website/jest.setup.ts b/src/website/jest.setup.ts index fc8a0c8077..1cc07c8531 100644 --- a/src/website/jest.setup.ts +++ b/src/website/jest.setup.ts @@ -1,2 +1 @@ -// jest.setup.ts -import '@testing-library/jest-dom/extend-expect'; +import './src/__mocks__/setup'; diff --git a/src/website/package-lock.json b/src/website/package-lock.json index d6a1fbdcb7..dcc4de423b 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -54,11 +54,16 @@ "devDependencies": { "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@types/chai": "^4.3.11", "@types/jest": "^29.5.13", + "@types/mocha": "^10.0.6", "@types/node": "^20", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@types/react-syntax-highlighter": "^15.5.13", + "chai": "^5.1.2", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", "eslint": "^8", "eslint-config-next": "^14.2.35", "eslint-config-prettier": "^9.1.0", @@ -69,10 +74,14 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "mocha": "^11.1.0", + "mochawesome": "^7.1.3", "postcss": "^8", "prettier": "^3.3.3", + "selenium-webdriver": "^4.27.0", "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "5.5.x" } }, @@ -625,6 +634,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -632,6 +648,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -3444,6 +3484,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3508,6 +3576,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3619,6 +3694,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -4648,6 +4730,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4906,6 +4998,13 @@ "node": ">=8" } }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -5099,6 +5198,23 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5156,6 +5272,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5351,6 +5477,13 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -5373,6 +5506,32 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5535,6 +5694,16 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5552,6 +5721,19 @@ } } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5587,6 +5769,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5689,6 +5881,16 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5749,6 +5951,19 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6004,6 +6219,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6784,6 +7006,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -6962,6 +7194,31 @@ } } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6982,6 +7239,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fsu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/fsu/-/fsu-1.1.1.tgz", + "integrity": "sha512-xQVsnjJ/5pQtcKh+KjUoZGzVWn4uNkchxTF6Lwjr4Gf7nQr8fmUfhKJ62zE77+xQg9xnxi5KUps7XGs+VC986A==", + "dev": true, + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7433,6 +7697,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -7562,6 +7836,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immer": { "version": "11.1.4", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", @@ -8021,6 +8302,16 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "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", @@ -8140,6 +8431,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -9393,6 +9697,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9406,6 +9717,29 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jspdf": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", @@ -9448,6 +9782,26 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9512,6 +9866,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9552,6 +9916,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -9559,6 +9930,27 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isobject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", + "integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9573,6 +9965,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -9598,6 +10007,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -9782,6 +10198,188 @@ "node": ">=8" } }, + "node_modules/mocha": { + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mochawesome": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/mochawesome/-/mochawesome-7.1.4.tgz", + "integrity": "sha512-fucGSh8643QkSvNRFOaJ3+kfjF0FhA/YtvDncnRAG0A4oCtAzHIFkt/+SgsWil1uwoeT+Nu5fsAnrKkFtnPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "diff": "^5.0.0", + "json-stringify-safe": "^5.0.1", + "lodash.isempty": "^4.4.0", + "lodash.isfunction": "^3.0.9", + "lodash.isobject": "^3.0.2", + "lodash.isstring": "^4.0.1", + "mochawesome-report-generator": "^6.3.0", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "mocha": ">=7" + } + }, + "node_modules/mochawesome-report-generator": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/mochawesome-report-generator/-/mochawesome-report-generator-6.3.2.tgz", + "integrity": "sha512-iB6iyOUMyMr8XOUYTNfrqYuZQLZka3K/Gr2GPc6CHPe7t2ZhxxfcoVkpMLOtyDKnWbY1zgu1/7VNRsigrvKnOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "dateformat": "^4.5.1", + "escape-html": "^1.0.3", + "fs-extra": "^10.0.0", + "fsu": "^1.1.1", + "lodash.isfunction": "^3.0.9", + "opener": "^1.5.2", + "prop-types": "^15.7.2", + "tcomb": "^3.2.17", + "tcomb-validation": "^3.3.0", + "yargs": "^17.2.1" + }, + "bin": { + "marge": "bin/cli.js" + } + }, + "node_modules/mochawesome/node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -10176,6 +10774,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10254,6 +10862,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -10397,6 +11012,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -10766,6 +11391,13 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10908,6 +11540,16 @@ "performance-now": "^2.1.0" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -11110,6 +11752,29 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -11396,6 +12061,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -11480,6 +12152,32 @@ "loose-envify": "^1.1.0" } }, + "node_modules/selenium-webdriver": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.45.0.tgz", + "integrity": "sha512-Cb2nqvJiwXVOtRTCYHX9D1FJR5+Ls7aL3Nev0t6n4CpXsQ//YGiiUmSCbvTDDeLtbV85SZ46qmLab4SIYKXWRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.7", + "ws": "^8.21.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -11492,6 +12190,16 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11541,6 +12249,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -11827,6 +12542,16 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -12292,6 +13017,23 @@ "node": ">=8.10.0" } }, + "node_modules/tcomb": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/tcomb/-/tcomb-3.2.29.tgz", + "integrity": "sha512-di2Hd1DB2Zfw6StGv861JoAF5h/uQVu/QJp2g8KVbtfKnoHdBQl5M32YWq6mnSYBQ1vFFrns5B1haWJL7rKaOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tcomb-validation": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tcomb-validation/-/tcomb-validation-3.4.1.tgz", + "integrity": "sha512-urVVMQOma4RXwiVCa2nM2eqrAomHROHvWPuj6UkDGz/eb5kcy0x6P0dVt6kzpUZtYMNoAqJLWmz1BPtxrtjtrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tcomb": "^3.0.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12411,6 +13153,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -12544,6 +13296,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12918,6 +13731,24 @@ "base64-arraybuffer": "^1.0.2" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13132,6 +13963,13 @@ "dev": true, "license": "MIT" }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13191,9 +14029,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -13275,6 +14113,45 @@ "node": ">=12" } }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/website/package.json b/src/website/package.json index 7db52c398c..b3e75e91ac 100644 --- a/src/website/package.json +++ b/src/website/package.json @@ -11,7 +11,9 @@ "format": "prettier --write .", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "e2e": "cross-env TS_NODE_PROJECT=e2e/tsconfig.json mocha --config e2e/.mocharc.yml", + "e2e:headed": "cross-env TS_NODE_PROJECT=e2e/tsconfig.json HEADLESS=false mocha --config e2e/.mocharc.yml" }, "dependencies": { "@airqo/icons-react": "0.2.7", @@ -60,11 +62,16 @@ "devDependencies": { "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@types/chai": "^4.3.11", "@types/jest": "^29.5.13", + "@types/mocha": "^10.0.6", "@types/node": "^20", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@types/react-syntax-highlighter": "^15.5.13", + "chai": "^5.1.2", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", "eslint": "^8", "eslint-config-next": "^14.2.35", "eslint-config-prettier": "^9.1.0", @@ -75,10 +82,14 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "mocha": "^11.1.0", + "mochawesome": "^7.1.3", "postcss": "^8", "prettier": "^3.3.3", + "selenium-webdriver": "^4.27.0", "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "5.5.x" } } diff --git a/src/website/src/__mocks__/fileMock.ts b/src/website/src/__mocks__/fileMock.ts new file mode 100644 index 0000000000..86059f3629 --- /dev/null +++ b/src/website/src/__mocks__/fileMock.ts @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/src/website/src/__mocks__/mapbox-gl.ts b/src/website/src/__mocks__/mapbox-gl.ts new file mode 100644 index 0000000000..e68ab8bd34 --- /dev/null +++ b/src/website/src/__mocks__/mapbox-gl.ts @@ -0,0 +1,44 @@ +const mapboxgl = { + Map: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + addSource: jest.fn(), + addLayer: jest.fn(), + removeLayer: jest.fn(), + removeSource: jest.fn(), + loadImage: jest.fn(), + addImage: jest.fn(), + fitBounds: jest.fn(), + flyTo: jest.fn(), + resize: jest.fn(), + getCanvas: jest.fn().mockReturnValue({ style: {} }), + remove: jest.fn(), + getBounds: jest.fn().mockReturnValue({ + getNorthEast: jest.fn().mockReturnValue({ lng: 0, lat: 0 }), + getSouthWest: jest.fn().mockReturnValue({ lng: 0, lat: 0 }), + }), + })), + Popup: jest.fn().mockImplementation(() => ({ + setLngLat: jest.fn().mockReturnThis(), + setHTML: jest.fn().mockReturnThis(), + addTo: jest.fn().mockReturnThis(), + remove: jest.fn(), + isOpen: jest.fn().mockReturnValue(false), + })), + Marker: jest.fn().mockImplementation(() => ({ + setLngLat: jest.fn().mockReturnThis(), + setPopup: jest.fn().mockReturnThis(), + addTo: jest.fn().mockReturnThis(), + remove: jest.fn(), + getElement: jest.fn().mockReturnValue(document.createElement('div')), + })), + NavigationControl: jest.fn().mockImplementation(() => ({})), + GeolocateControl: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + })), + LngLatBounds: jest.fn().mockImplementation(() => ({ + extend: jest.fn().mockReturnThis(), + isEmpty: jest.fn().mockReturnValue(true), + })), +}; + +module.exports = mapboxgl; diff --git a/src/website/src/__mocks__/recharts.tsx b/src/website/src/__mocks__/recharts.tsx new file mode 100644 index 0000000000..bfd4d043f3 --- /dev/null +++ b/src/website/src/__mocks__/recharts.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +function createChartComponent(name: string) { + const Component = React.forwardRef(function MockComponent( + props: any, + ref: any, + ) { + return React.createElement('div', { + ref, + 'data-testid': name.toLowerCase(), + ...props, + }); + }); + Component.displayName = name; + return Component; +} + +const line = createChartComponent('Line'); +const area = createChartComponent('Area'); +const bar = createChartComponent('Bar'); +const pie = createChartComponent('Pie'); +const cell = createChartComponent('Cell'); +const xAxis = createChartComponent('XAxis'); +const yAxis = createChartComponent('YAxis'); +const cartesianGrid = createChartComponent('CartesianGrid'); +const tooltip = createChartComponent('Tooltip'); +const legend = createChartComponent('Legend'); +const responsiveContainer = createChartComponent('ResponsiveContainer'); + +const RechartsMock = { + LineChart: createChartComponent('LineChart'), + Line: line, + BarChart: createChartComponent('BarChart'), + Bar: bar, + PieChart: createChartComponent('PieChart'), + Pie: pie, + Cell: cell, + AreaChart: createChartComponent('AreaChart'), + Area: area, + XAxis: xAxis, + YAxis: yAxis, + CartesianGrid: cartesianGrid, + Tooltip: tooltip, + Legend: legend, + ResponsiveContainer: responsiveContainer, + ReferenceLine: createChartComponent('ReferenceLine'), + ReferenceArea: createChartComponent('ReferenceArea'), + ComposedChart: createChartComponent('ComposedChart'), + Radar: createChartComponent('Radar'), + RadarChart: createChartComponent('RadarChart'), + ScatterChart: createChartComponent('ScatterChart'), + Scatter: createChartComponent('Scatter'), + Treemap: createChartComponent('Treemap'), + Funnel: createChartComponent('Funnel'), + FunnelChart: createChartComponent('FunnelChart'), +}; + +export default RechartsMock; diff --git a/src/website/src/__mocks__/setup.ts b/src/website/src/__mocks__/setup.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/src/website/src/__mocks__/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/website/src/app/(main)/blogs/[slug]/page.tsx b/src/website/src/app/(content)/blogs/[slug]/page.tsx similarity index 95% rename from src/website/src/app/(main)/blogs/[slug]/page.tsx rename to src/website/src/app/(content)/blogs/[slug]/page.tsx index 4c1d8e53e1..c4e9ce9973 100644 --- a/src/website/src/app/(main)/blogs/[slug]/page.tsx +++ b/src/website/src/app/(content)/blogs/[slug]/page.tsx @@ -2,11 +2,11 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; +import BlogDetailPage from '@/features/blogs/BlogDetailPage'; import { generateViewport, METADATA_CONFIGS } from '@/lib/metadata'; import { buildSiteUrl } from '@/lib/siteUrl'; -import { blogService } from '@/services/apiService'; -import type { BlogPost } from '@/services/types/api'; -import BlogDetailPage from '@/views/blogs/BlogDetailPage'; +import { blogService } from '@/services/website'; +import type { BlogPost } from '@/types/api'; export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/blogs/layout.tsx b/src/website/src/app/(content)/blogs/layout.tsx similarity index 84% rename from src/website/src/app/(main)/blogs/layout.tsx rename to src/website/src/app/(content)/blogs/layout.tsx index 5a88ab870f..4ca5b71276 100644 --- a/src/website/src/app/(main)/blogs/layout.tsx +++ b/src/website/src/app/(content)/blogs/layout.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; type BlogsLayoutProps = { children: React.ReactNode; diff --git a/src/website/src/app/(main)/blogs/page.tsx b/src/website/src/app/(content)/blogs/page.tsx similarity index 88% rename from src/website/src/app/(main)/blogs/page.tsx rename to src/website/src/app/(content)/blogs/page.tsx index f542c2d847..98c676f541 100644 --- a/src/website/src/app/(main)/blogs/page.tsx +++ b/src/website/src/app/(content)/blogs/page.tsx @@ -1,11 +1,11 @@ import { Suspense } from 'react'; +import BlogsPage from '@/features/blogs/BlogsPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import BlogsPage from '@/views/blogs/BlogsPage'; export const metadata = createMetadata(METADATA_CONFIGS.blogs); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/developers/airqo-devcon/page.tsx b/src/website/src/app/(developers)/developers/airqo-devcon/page.tsx similarity index 74% rename from src/website/src/app/(main)/developers/airqo-devcon/page.tsx rename to src/website/src/app/(developers)/developers/airqo-devcon/page.tsx index f7a6a7e4d2..7380481420 100644 --- a/src/website/src/app/(main)/developers/airqo-devcon/page.tsx +++ b/src/website/src/app/(developers)/developers/airqo-devcon/page.tsx @@ -1,10 +1,10 @@ -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; +import AirQoDevConPage from '@/features/developers/airqo-devcon/AirQoDevConPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AirQoDevConPage from '@/views/developers/airqo-devcon/AirQoDevConPage'; export const metadata = createMetadata(METADATA_CONFIGS.airqoDevCon); export const viewport = generateViewport(); diff --git a/src/website/src/app/packages/icons/docs/page.tsx b/src/website/src/app/(developers)/packages/icons/docs/page.tsx similarity index 88% rename from src/website/src/app/packages/icons/docs/page.tsx rename to src/website/src/app/(developers)/packages/icons/docs/page.tsx index be6c1e5758..996ad124c8 100644 --- a/src/website/src/app/packages/icons/docs/page.tsx +++ b/src/website/src/app/(developers)/packages/icons/docs/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; -import { IconsDocsPage } from '@/views/packages/icons/docs'; +import { IconsDocsPage } from '@/features/packages/icons/docs'; export const metadata: Metadata = { title: 'AirQo Icons Documentation - React, Vue, & Flutter', diff --git a/src/website/src/app/packages/icons/page.tsx b/src/website/src/app/(developers)/packages/icons/page.tsx similarity index 93% rename from src/website/src/app/packages/icons/page.tsx rename to src/website/src/app/(developers)/packages/icons/page.tsx index d3bb4eebe5..4067032bb6 100644 --- a/src/website/src/app/packages/icons/page.tsx +++ b/src/website/src/app/(developers)/packages/icons/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import IconsPackagePage from '@/views/packages/IconsPackagePage'; +import IconsPackagePage from '@/features/packages/IconsPackagePage'; export const metadata: Metadata = { title: 'AirQo Icons', diff --git a/src/website/src/app/packages/layout.tsx b/src/website/src/app/(developers)/packages/layout.tsx similarity index 94% rename from src/website/src/app/packages/layout.tsx rename to src/website/src/app/(developers)/packages/layout.tsx index 897cdaab42..006412f72a 100644 --- a/src/website/src/app/packages/layout.tsx +++ b/src/website/src/app/(developers)/packages/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; export const metadata: Metadata = { title: { diff --git a/src/website/src/app/packages/page.tsx b/src/website/src/app/(developers)/packages/page.tsx similarity index 93% rename from src/website/src/app/packages/page.tsx rename to src/website/src/app/(developers)/packages/page.tsx index f655827aae..e183c10185 100644 --- a/src/website/src/app/packages/page.tsx +++ b/src/website/src/app/(developers)/packages/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import PackagesPage from '@/views/packages/PackagesPage'; +import PackagesPage from '@/features/packages/PackagesPage'; export const metadata: Metadata = { title: 'Open Source Packages', diff --git a/src/website/src/app/(main)/legal/airqo-data/page.tsx b/src/website/src/app/(legal)/legal/airqo-data/page.tsx similarity index 75% rename from src/website/src/app/(main)/legal/airqo-data/page.tsx rename to src/website/src/app/(legal)/legal/airqo-data/page.tsx index 2fcc3f1159..d2ef65a127 100644 --- a/src/website/src/app/(main)/legal/airqo-data/page.tsx +++ b/src/website/src/app/(legal)/legal/airqo-data/page.tsx @@ -1,11 +1,10 @@ +import AirQoDataPage from '@/features/legal/AirQoDataPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AirQoDataPage from '@/views/legal/AirQoDataPage'; -// Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.airqoDataPolicy); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/legal/cookies/page.tsx b/src/website/src/app/(legal)/legal/cookies/page.tsx similarity index 85% rename from src/website/src/app/(main)/legal/cookies/page.tsx rename to src/website/src/app/(legal)/legal/cookies/page.tsx index 80c5a67da2..10031c6b19 100644 --- a/src/website/src/app/(main)/legal/cookies/page.tsx +++ b/src/website/src/app/(legal)/legal/cookies/page.tsx @@ -1,9 +1,9 @@ +import CookiesPolicyPage from '@/features/legal/CookiesPolicyPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import CookiesPolicyPage from '@/views/legal/CookiesPolicyPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.cookiesPolicy); diff --git a/src/website/src/app/(main)/legal/layout.tsx b/src/website/src/app/(legal)/legal/layout.tsx similarity index 89% rename from src/website/src/app/(main)/legal/layout.tsx rename to src/website/src/app/(legal)/legal/layout.tsx index c2181a39cb..26cc7c5201 100644 --- a/src/website/src/app/(main)/legal/layout.tsx +++ b/src/website/src/app/(legal)/legal/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from 'next'; import type React from 'react'; -import MainLayout from '@/components/layouts/MainLayout'; -import mainConfig from '@/configs/mainConfigs'; -import TabSection from '@/views/legal/Tabsection'; +import MainLayout from '@/components/layout/MainLayout'; +import mainConfig from '@/config/site.config'; +import TabSection from '@/features/legal/Tabsection'; export const metadata: Metadata = { title: { diff --git a/src/website/src/app/(main)/legal/payment-refund-policy/page.tsx b/src/website/src/app/(legal)/legal/payment-refund-policy/page.tsx similarity index 77% rename from src/website/src/app/(main)/legal/payment-refund-policy/page.tsx rename to src/website/src/app/(legal)/legal/payment-refund-policy/page.tsx index 0f4c59f4ce..6ee7291301 100644 --- a/src/website/src/app/(main)/legal/payment-refund-policy/page.tsx +++ b/src/website/src/app/(legal)/legal/payment-refund-policy/page.tsx @@ -1,11 +1,10 @@ +import PRP_Page from '@/features/legal/PRP_Page'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import PRP_Page from '@/views/legal/PRP_Page'; -// Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.paymentRefundPolicy); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/legal/privacy-policy/page.tsx b/src/website/src/app/(legal)/legal/privacy-policy/page.tsx similarity index 88% rename from src/website/src/app/(main)/legal/privacy-policy/page.tsx rename to src/website/src/app/(legal)/legal/privacy-policy/page.tsx index bb6e8a80fc..b032c8fe00 100644 --- a/src/website/src/app/(main)/legal/privacy-policy/page.tsx +++ b/src/website/src/app/(legal)/legal/privacy-policy/page.tsx @@ -1,9 +1,9 @@ +import PP_Page from '@/features/legal/PP_Page'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import PP_Page from '@/views/legal/PP_Page'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.privacyPolicy); diff --git a/src/website/src/app/(main)/legal/terms-of-service/page.tsx b/src/website/src/app/(legal)/legal/terms-of-service/page.tsx similarity index 88% rename from src/website/src/app/(main)/legal/terms-of-service/page.tsx rename to src/website/src/app/(legal)/legal/terms-of-service/page.tsx index c721236584..32ff985109 100644 --- a/src/website/src/app/(main)/legal/terms-of-service/page.tsx +++ b/src/website/src/app/(legal)/legal/terms-of-service/page.tsx @@ -1,9 +1,9 @@ +import TOSPage from '@/features/legal/TOSPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import TOSPage from '@/views/legal/TOSPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.termsOfService); diff --git a/src/website/src/app/(main)/(about)/layout.tsx b/src/website/src/app/(main)/(about)/layout.tsx deleted file mode 100644 index a03c62058d..0000000000 --- a/src/website/src/app/(main)/(about)/layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Metadata } from 'next'; -import React from 'react'; - -import MainLayout from '@/components/layouts/MainLayout'; - -export const metadata: Metadata = { - title: { - template: '%s | AirQo About', - default: 'About AirQo | Leading Air Quality Monitoring in Africa', - }, - description: - 'Learn about AirQo, our mission to bridge the air quality data gap in Africa, our team, impact, and how we empower communities with accurate air quality information.', - keywords: - 'About AirQo, AirQo mission, African air quality organization, environmental monitoring team, clean air advocacy, air quality research Africa', -}; - -type AboutLayoutProps = { - children: React.ReactNode; -}; - -const AboutLayout: React.FC = ({ children }) => { - return {children}; -}; - -export default AboutLayout; diff --git a/src/website/src/app/(main)/products/ai-platform/page.tsx b/src/website/src/app/(products)/products/ai-platform/page.tsx similarity index 83% rename from src/website/src/app/(main)/products/ai-platform/page.tsx rename to src/website/src/app/(products)/products/ai-platform/page.tsx index 630b259150..f361401b22 100644 --- a/src/website/src/app/(main)/products/ai-platform/page.tsx +++ b/src/website/src/app/(products)/products/ai-platform/page.tsx @@ -1,9 +1,9 @@ +import AIPlatformPage from '@/features/products/AIPlatformPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AIPlatformPage from '@/views/products/AIPlatformPage'; export const metadata = createMetadata(METADATA_CONFIGS.aiPlatform); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/analytics/page.tsx b/src/website/src/app/(products)/products/analytics/page.tsx similarity index 84% rename from src/website/src/app/(main)/products/analytics/page.tsx rename to src/website/src/app/(products)/products/analytics/page.tsx index 4ac3585e31..84d6250c11 100644 --- a/src/website/src/app/(main)/products/analytics/page.tsx +++ b/src/website/src/app/(products)/products/analytics/page.tsx @@ -1,9 +1,9 @@ +import AnalyticsPage from '@/features/products/AnalyticsPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AnalyticsPage from '@/views/products/AnalyticsPage'; export const metadata = createMetadata(METADATA_CONFIGS.analytics); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/api/page.tsx b/src/website/src/app/(products)/products/api/page.tsx similarity index 86% rename from src/website/src/app/(main)/products/api/page.tsx rename to src/website/src/app/(products)/products/api/page.tsx index 5843ee89a4..60c1e140d6 100644 --- a/src/website/src/app/(main)/products/api/page.tsx +++ b/src/website/src/app/(products)/products/api/page.tsx @@ -1,9 +1,9 @@ +import ApiPage from '@/features/products/ApiPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import ApiPage from '@/views/products/ApiPage'; export const metadata = createMetadata(METADATA_CONFIGS.api); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/beacon/page.tsx b/src/website/src/app/(products)/products/beacon/page.tsx similarity index 85% rename from src/website/src/app/(main)/products/beacon/page.tsx rename to src/website/src/app/(products)/products/beacon/page.tsx index 4a63a5a3fc..7c3dd99505 100644 --- a/src/website/src/app/(main)/products/beacon/page.tsx +++ b/src/website/src/app/(products)/products/beacon/page.tsx @@ -1,9 +1,9 @@ +import BeaconPage from '@/features/products/BeaconPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import BeaconPage from '@/views/products/BeaconPage'; export const metadata = createMetadata(METADATA_CONFIGS.beacon); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/calibrate/page.tsx b/src/website/src/app/(products)/products/calibrate/page.tsx similarity index 84% rename from src/website/src/app/(main)/products/calibrate/page.tsx rename to src/website/src/app/(products)/products/calibrate/page.tsx index a14a7ecf64..4203c7d83b 100644 --- a/src/website/src/app/(main)/products/calibrate/page.tsx +++ b/src/website/src/app/(products)/products/calibrate/page.tsx @@ -1,9 +1,9 @@ +import CalibratePage from '@/features/products/CalibratePage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import CalibratePage from '@/views/products/CalibratePage'; export const metadata = createMetadata(METADATA_CONFIGS.calibrate); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/layout.tsx b/src/website/src/app/(products)/products/layout.tsx similarity index 80% rename from src/website/src/app/(main)/products/layout.tsx rename to src/website/src/app/(products)/products/layout.tsx index 744a20d4f6..62963f8c5b 100644 --- a/src/website/src/app/(main)/products/layout.tsx +++ b/src/website/src/app/(products)/products/layout.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; type ProductsLayoutProps = { children: React.ReactNode; diff --git a/src/website/src/app/(main)/products/mobile-app/page.tsx b/src/website/src/app/(products)/products/mobile-app/page.tsx similarity index 85% rename from src/website/src/app/(main)/products/mobile-app/page.tsx rename to src/website/src/app/(products)/products/mobile-app/page.tsx index 08d2e8d8d3..5e49984042 100644 --- a/src/website/src/app/(main)/products/mobile-app/page.tsx +++ b/src/website/src/app/(products)/products/mobile-app/page.tsx @@ -1,9 +1,9 @@ +import MobilePage from '@/features/products/MobilePage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import MobilePage from '@/views/products/MobilePage'; export const metadata = createMetadata(METADATA_CONFIGS.mobileApp); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/monitor/page.tsx b/src/website/src/app/(products)/products/monitor/page.tsx similarity index 84% rename from src/website/src/app/(main)/products/monitor/page.tsx rename to src/website/src/app/(products)/products/monitor/page.tsx index e816012646..ec6d030838 100644 --- a/src/website/src/app/(main)/products/monitor/page.tsx +++ b/src/website/src/app/(products)/products/monitor/page.tsx @@ -1,9 +1,9 @@ +import MonitorPage from '@/features/products/MonitorPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import MonitorPage from '@/views/products/MonitorPage'; export const metadata = createMetadata(METADATA_CONFIGS.monitor); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/products/vertex/page.tsx b/src/website/src/app/(products)/products/vertex/page.tsx similarity index 85% rename from src/website/src/app/(main)/products/vertex/page.tsx rename to src/website/src/app/(products)/products/vertex/page.tsx index df6b27f50d..4ed49311de 100644 --- a/src/website/src/app/(main)/products/vertex/page.tsx +++ b/src/website/src/app/(products)/products/vertex/page.tsx @@ -1,9 +1,9 @@ +import VertexPage from '@/features/products/VertexPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import VertexPage from '@/views/products/VertexPage'; export const metadata = createMetadata(METADATA_CONFIGS.vertex); export const viewport = generateViewport(); diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/[...slug]/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/[...slug]/page.tsx similarity index 100% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/[...slug]/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/[...slug]/page.tsx diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx similarity index 90% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx index 412f365f1d..576769a990 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/about/page.tsx @@ -6,7 +6,7 @@ import { } from '@/lib/metadata'; const AboutPage = dynamic( - () => import('@/views/cleanairforum/about/AboutPage'), + () => import('@/features/clean-air-forum/about/AboutPage'), { ssr: false }, ); diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/program-committee/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/committee/page.tsx similarity index 74% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/program-committee/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/committee/page.tsx index 375bab9788..e3d09d3f83 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/program-committee/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/committee/page.tsx @@ -1,8 +1,8 @@ +import CommitteePage from '@/features/clean-air-forum/program-committee/CommitteePage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import CommitteePage from '@/views/cleanairforum/program-committee/CommitteePage'; export const metadata = createMetadata( METADATA_CONFIGS.cleanAirForumProgramCommittee, diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx similarity index 86% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx index d3c733f484..05a79baf28 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/glossary/page.tsx @@ -2,11 +2,11 @@ // If you later change this route to redirect to the canonical // `/africa-clean-air-forum` endpoint, prefer `permanentRedirect` // from `next/navigation` (308) instead of `redirect` (307). +import GlossaryPage from '@/features/clean-air-forum/glossary/GlossaryPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import GlossaryPage from '@/views/cleanairforum/glossary/GlossaryPage'; export const metadata = createMetadata(METADATA_CONFIGS.cleanAirForumGlossary); diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/layout.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/layout.tsx similarity index 90% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/layout.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/layout.tsx index 3aa234a159..b0ba6bcab4 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/layout.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/layout.tsx @@ -4,15 +4,15 @@ import { useParams, useRouter } from 'next/navigation'; import React, { ReactNode } from 'react'; -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; import { NoData, CustomButton } from '@/components/ui'; import ForumDetailSkeleton from '@/components/ui/ForumDetailSkeleton'; -import { ForumDataProvider } from '@/context/ForumDataContext'; +import { ForumDataProvider } from '@/components/providers/ForumDataContext'; import { useForumEventDetails } from '@/hooks/useApiHooks'; -import { ForumEventDetail } from '@/services/types/api'; -import logger from '@/utils/logger'; +import { ForumEventDetail } from '@/types/api'; +import logger from '@/lib/utils/logger'; -import BannerSection from '../../../../views/cleanairforum/BannerSection'; +import BannerSection from '@/features/clean-air-forum/components/BannerSection'; type ForumEventLayoutProps = { children: ReactNode; diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx similarity index 85% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx index c566646797..9d422de0a9 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/logistics/page.tsx @@ -1,8 +1,8 @@ +import LogisticsPage from '@/features/clean-air-forum/logistics/LogisticsPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import LogisticsPage from '@/views/cleanairforum/logistics/LogisticsPage'; export function generateMetadata({ params, diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/page.tsx similarity index 100% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/page.tsx diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx similarity index 76% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx index 5547a3eefa..bc0440713a 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/partners/page.tsx @@ -1,8 +1,8 @@ +import PartnersPage from '@/features/clean-air-forum/partners/PartnersPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import PartnersPage from '@/views/cleanairforum/partners/PartnersPage'; export const metadata = createMetadata(METADATA_CONFIGS.cleanAirForumPartners); diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sessions/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/program/page.tsx similarity index 74% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sessions/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/program/page.tsx index 67f888e21f..6dc50958a0 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sessions/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/program/page.tsx @@ -1,8 +1,8 @@ +import ProgramsPage from '@/features/clean-air-forum/sessions-programs/ProgramsPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import ProgramsPage from '@/views/cleanairforum/sessions-programs/ProgramsPage'; export const metadata = createMetadata(METADATA_CONFIGS.cleanAirForumSessions); diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx similarity index 85% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx index 9b3583b5ce..6a48db09fa 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/resources/page.tsx @@ -1,8 +1,8 @@ +import ResourcesPage from '@/features/clean-air-forum/resources/ResourcesPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import ResourcesPage from '@/views/cleanairforum/resources/ResourcesPage'; export function generateMetadata({ params, diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx similarity index 85% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx index 744df83c8e..b0eae87523 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/speakers/page.tsx @@ -1,8 +1,8 @@ +import SpeakersPage from '@/features/clean-air-forum/speakers/SpeakersPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import SpeakersPage from '@/views/cleanairforum/speakers/SpeakersPage'; export function generateMetadata({ params, diff --git a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sponsorships/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/sponsorship/page.tsx similarity index 75% rename from src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sponsorships/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/sponsorship/page.tsx index abab9298a4..6ba21d7040 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/[uniqueTitle]/sponsorships/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/[uniqueTitle]/sponsorship/page.tsx @@ -1,8 +1,8 @@ +import SponsorshipPage from '@/features/clean-air-forum/sponsorship/SponsorshipPage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import SponsorshipPage from '@/views/cleanairforum/sponsorship/SponsorshipPage'; export const metadata = createMetadata( METADATA_CONFIGS.cleanAirForumSponsorships, diff --git a/src/website/src/app/(main)/africa-clean-air-forum/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum/page.tsx similarity index 81% rename from src/website/src/app/(main)/africa-clean-air-forum/page.tsx rename to src/website/src/app/(programs)/africa-clean-air-forum/page.tsx index 4859c253a9..267cebb482 100644 --- a/src/website/src/app/(main)/africa-clean-air-forum/page.tsx +++ b/src/website/src/app/(programs)/africa-clean-air-forum/page.tsx @@ -1,11 +1,10 @@ -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; +import ForumHero from '@/features/clean-air-forum/components/ForumHero'; +import ForumEventsPage from '@/features/clean-air-forum/ForumEventsPage'; import { generateMetadata as createMetadata, generateViewport, } from '@/lib/metadata'; -import ForumEventsPage from '@/views/cleanairforum/ForumEventsPage'; -import ForumHero from '@/views/cleanairforum/ForumHero'; - // Generate metadata for SEO export const metadata = createMetadata({ title: "Africa Clean Air Forum | Africa's Premier Air Quality Convening", diff --git a/src/website/src/app/(main)/(about)/about-us/page.tsx b/src/website/src/app/(site)/about-us/page.tsx similarity index 67% rename from src/website/src/app/(main)/(about)/about-us/page.tsx rename to src/website/src/app/(site)/about-us/page.tsx index 3030fc6f8e..a1ec14db19 100644 --- a/src/website/src/app/(main)/(about)/about-us/page.tsx +++ b/src/website/src/app/(site)/about-us/page.tsx @@ -1,18 +1,19 @@ +import MainLayout from '@/components/layout/MainLayout'; +import AboutPage from '@/features/about/AboutPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AboutPage from '@/views/about/AboutPage'; export const metadata = createMetadata(METADATA_CONFIGS.about); export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/(about)/careers/[id]/page.tsx b/src/website/src/app/(site)/careers/[id]/page.tsx similarity index 93% rename from src/website/src/app/(main)/(about)/careers/[id]/page.tsx rename to src/website/src/app/(site)/careers/[id]/page.tsx index 3a8d3a7e9a..696a0d47dc 100644 --- a/src/website/src/app/(main)/(about)/careers/[id]/page.tsx +++ b/src/website/src/app/(site)/careers/[id]/page.tsx @@ -1,8 +1,8 @@ import { Metadata } from 'next'; import { headers } from 'next/headers'; +import DetailsPage from '@/features/careers/DetailsPage'; import { buildSiteUrl } from '@/lib/siteUrl'; -import DetailsPage from '@/views/careers/DetailsPage'; export async function generateMetadata({ params, @@ -51,7 +51,7 @@ export async function generateMetadata({ }; } -const page = ({ params }: { params: any }) => { +const page = ({ params }: { params: { id: string } }) => { return (
diff --git a/src/website/src/app/(main)/(about)/careers/layout.tsx b/src/website/src/app/(site)/careers/layout.tsx similarity index 100% rename from src/website/src/app/(main)/(about)/careers/layout.tsx rename to src/website/src/app/(site)/careers/layout.tsx diff --git a/src/website/src/app/(main)/(about)/careers/page.tsx b/src/website/src/app/(site)/careers/page.tsx similarity index 70% rename from src/website/src/app/(main)/(about)/careers/page.tsx rename to src/website/src/app/(site)/careers/page.tsx index 001cf410d8..786eba9566 100644 --- a/src/website/src/app/(main)/(about)/careers/page.tsx +++ b/src/website/src/app/(site)/careers/page.tsx @@ -1,9 +1,10 @@ +import MainLayout from '@/components/layout/MainLayout'; +import CareerPage from '@/features/careers/CareerPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import CareerPage from '@/views/careers/CareerPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.careers); @@ -11,9 +12,9 @@ export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/contact/ContactPage.tsx b/src/website/src/app/(site)/contact/ContactPage.tsx similarity index 100% rename from src/website/src/app/(main)/contact/ContactPage.tsx rename to src/website/src/app/(site)/contact/ContactPage.tsx diff --git a/src/website/src/app/(main)/contact/form/FormPage.tsx b/src/website/src/app/(site)/contact/form/FormPage.tsx similarity index 99% rename from src/website/src/app/(main)/contact/form/FormPage.tsx rename to src/website/src/app/(site)/contact/form/FormPage.tsx index 7dee917de6..2d0c38d10d 100644 --- a/src/website/src/app/(main)/contact/form/FormPage.tsx +++ b/src/website/src/app/(site)/contact/form/FormPage.tsx @@ -6,7 +6,7 @@ import React, { useState } from 'react'; import { FiArrowLeft } from 'react-icons/fi'; import { CustomButton } from '@/components/ui'; -import { externalService } from '@/services/apiService'; +import { externalService } from '@/services/external'; const FormPage: React.FC = () => { const router = useRouter(); diff --git a/src/website/src/app/(main)/contact/form/page.tsx b/src/website/src/app/(site)/contact/form/page.tsx similarity index 100% rename from src/website/src/app/(main)/contact/form/page.tsx rename to src/website/src/app/(site)/contact/form/page.tsx diff --git a/src/website/src/app/(main)/contact/layout.tsx b/src/website/src/app/(site)/contact/layout.tsx similarity index 75% rename from src/website/src/app/(main)/contact/layout.tsx rename to src/website/src/app/(site)/contact/layout.tsx index 713167878b..9e6073f80b 100644 --- a/src/website/src/app/(main)/contact/layout.tsx +++ b/src/website/src/app/(site)/contact/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import Navbar from '@/components/layouts/Navbar'; +import Navbar from '@/components/layout/Navbar'; export const metadata: Metadata = { title: 'Contact Us | AirQo - Get in Touch for Air Quality Solutions', @@ -17,11 +17,9 @@ type ContactLayoutProps = { const ContactLayout: React.FC = ({ children }) => { return ( -
-
- -
-
{children}
+
+ +
{children}
); }; diff --git a/src/website/src/app/(main)/contact/page.tsx b/src/website/src/app/(site)/contact/page.tsx similarity index 100% rename from src/website/src/app/(main)/contact/page.tsx rename to src/website/src/app/(site)/contact/page.tsx diff --git a/src/website/src/app/(main)/contact/success/SuccessPage.tsx b/src/website/src/app/(site)/contact/success/SuccessPage.tsx similarity index 100% rename from src/website/src/app/(main)/contact/success/SuccessPage.tsx rename to src/website/src/app/(site)/contact/success/SuccessPage.tsx diff --git a/src/website/src/app/(main)/contact/success/page.tsx b/src/website/src/app/(site)/contact/success/page.tsx similarity index 100% rename from src/website/src/app/(main)/contact/success/page.tsx rename to src/website/src/app/(site)/contact/success/page.tsx diff --git a/src/website/src/app/(main)/(about)/events/[id]/page.tsx b/src/website/src/app/(site)/events/[id]/page.tsx similarity index 90% rename from src/website/src/app/(main)/(about)/events/[id]/page.tsx rename to src/website/src/app/(site)/events/[id]/page.tsx index 0e62373989..77459dd64e 100644 --- a/src/website/src/app/(main)/(about)/events/[id]/page.tsx +++ b/src/website/src/app/(site)/events/[id]/page.tsx @@ -1,8 +1,9 @@ import { Metadata } from 'next'; import { headers } from 'next/headers'; +import MainLayout from '@/components/layout/MainLayout'; +import SingleEvent from '@/features/events/SingleEvent'; import { buildSiteUrl } from '@/lib/siteUrl'; -import SingleEvent from '@/views/events/SingleEvent'; export async function generateMetadata({ params, @@ -63,11 +64,11 @@ export async function generateMetadata({ }; } -const page = ({ params }: { params: any }) => { +const page = ({ params }: { params: { id: string } }) => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/(about)/events/layout.tsx b/src/website/src/app/(site)/events/layout.tsx similarity index 100% rename from src/website/src/app/(main)/(about)/events/layout.tsx rename to src/website/src/app/(site)/events/layout.tsx diff --git a/src/website/src/app/(main)/(about)/events/page.tsx b/src/website/src/app/(site)/events/page.tsx similarity index 71% rename from src/website/src/app/(main)/(about)/events/page.tsx rename to src/website/src/app/(site)/events/page.tsx index 679d8889f6..3672a08ccb 100644 --- a/src/website/src/app/(main)/(about)/events/page.tsx +++ b/src/website/src/app/(site)/events/page.tsx @@ -1,9 +1,10 @@ +import MainLayout from '@/components/layout/MainLayout'; +import EventPage from '@/features/events/EventPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import EventPage from '@/views/events/EventPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.events); @@ -11,9 +12,9 @@ export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/(about)/faqs/page.tsx b/src/website/src/app/(site)/faqs/page.tsx similarity index 72% rename from src/website/src/app/(main)/(about)/faqs/page.tsx rename to src/website/src/app/(site)/faqs/page.tsx index 949eca711f..c6eeda7d76 100644 --- a/src/website/src/app/(main)/(about)/faqs/page.tsx +++ b/src/website/src/app/(site)/faqs/page.tsx @@ -1,9 +1,10 @@ +import MainLayout from '@/components/layout/MainLayout'; +import { FAQPage } from '@/features/faqs'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import { FAQPage } from '@/views/faqs'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.faqs); @@ -11,9 +12,9 @@ export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/home/page.tsx b/src/website/src/app/(site)/home/page.tsx similarity index 78% rename from src/website/src/app/(main)/home/page.tsx rename to src/website/src/app/(site)/home/page.tsx index 6e0b259d04..4504e7db74 100644 --- a/src/website/src/app/(main)/home/page.tsx +++ b/src/website/src/app/(site)/home/page.tsx @@ -1,10 +1,10 @@ -import MainLayout from '@/components/layouts/MainLayout'; +import MainLayout from '@/components/layout/MainLayout'; +import HomePage from '@/features/home/HomePage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import HomePage from '@/views/home/HomePage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.home); diff --git a/src/website/src/app/(site)/layout.tsx b/src/website/src/app/(site)/layout.tsx new file mode 100644 index 0000000000..0100750b5e --- /dev/null +++ b/src/website/src/app/(site)/layout.tsx @@ -0,0 +1,21 @@ +import { Metadata } from 'next'; +import React from 'react'; + +export const metadata: Metadata = { + title: { + template: '%s | AirQo', + default: 'AirQo | Bridging the Air Quality Data Gap in Africa', + }, + description: + 'AirQo empowers African communities with accurate, hyperlocal, and timely air quality data to drive pollution mitigation actions.', +}; + +type SiteLayoutProps = { + children: React.ReactNode; +}; + +const SiteLayout: React.FC = ({ children }) => { + return <>{children}; +}; + +export default SiteLayout; diff --git a/src/website/src/app/(main)/partners/[id]/page.tsx b/src/website/src/app/(site)/partners/[id]/page.tsx similarity index 100% rename from src/website/src/app/(main)/partners/[id]/page.tsx rename to src/website/src/app/(site)/partners/[id]/page.tsx diff --git a/src/website/src/app/(main)/partners/layout.tsx b/src/website/src/app/(site)/partners/layout.tsx similarity index 95% rename from src/website/src/app/(main)/partners/layout.tsx rename to src/website/src/app/(site)/partners/layout.tsx index 2b1a5c6597..ec0a79d378 100644 --- a/src/website/src/app/(main)/partners/layout.tsx +++ b/src/website/src/app/(site)/partners/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import Navbar from '@/components/layouts/Navbar'; +import Navbar from '@/components/layout/Navbar'; export const metadata: Metadata = { title: 'Our Partners | AirQo Collaborations and Partnerships', diff --git a/src/website/src/app/(main)/(about)/press/page.tsx b/src/website/src/app/(site)/press/page.tsx similarity index 71% rename from src/website/src/app/(main)/(about)/press/page.tsx rename to src/website/src/app/(site)/press/page.tsx index fc068d17d2..f866a4d486 100644 --- a/src/website/src/app/(main)/(about)/press/page.tsx +++ b/src/website/src/app/(site)/press/page.tsx @@ -1,9 +1,10 @@ +import MainLayout from '@/components/layout/MainLayout'; +import PressPage from '@/features/press/PressPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import PressPage from '@/views/press/PressPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.press); @@ -11,9 +12,9 @@ export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/(about)/resources/page.tsx b/src/website/src/app/(site)/resources/page.tsx similarity index 74% rename from src/website/src/app/(main)/(about)/resources/page.tsx rename to src/website/src/app/(site)/resources/page.tsx index 4cbed9be0c..ee8b57fb46 100644 --- a/src/website/src/app/(main)/(about)/resources/page.tsx +++ b/src/website/src/app/(site)/resources/page.tsx @@ -1,11 +1,12 @@ import { Suspense } from 'react'; +import MainLayout from '@/components/layout/MainLayout'; +import ResourcePage from '@/features/resources/ResourcePage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import ResourcePage from '@/views/publications/ResourcePage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.resources); @@ -13,11 +14,11 @@ export const viewport = generateViewport(); const Page = () => { return ( -
+ -
+ ); }; diff --git a/src/website/src/app/(main)/solutions/african-cities/page.tsx b/src/website/src/app/(solutions)/solutions/african-cities/page.tsx similarity index 82% rename from src/website/src/app/(main)/solutions/african-cities/page.tsx rename to src/website/src/app/(solutions)/solutions/african-cities/page.tsx index 2d05de3c63..e7b3e0ef6c 100644 --- a/src/website/src/app/(main)/solutions/african-cities/page.tsx +++ b/src/website/src/app/(solutions)/solutions/african-cities/page.tsx @@ -1,9 +1,9 @@ +import AfricanCityPage from '@/features/solutions/african-cities/AfricanCityPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import AfricanCityPage from '@/views/solutions/AfricanCities/AfricanCityPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.solutionsAfricanCities); diff --git a/src/website/src/app/(main)/solutions/communities/page.tsx b/src/website/src/app/(solutions)/solutions/communities/page.tsx similarity index 83% rename from src/website/src/app/(main)/solutions/communities/page.tsx rename to src/website/src/app/(solutions)/solutions/communities/page.tsx index 0f95715b4f..71a87e497d 100644 --- a/src/website/src/app/(main)/solutions/communities/page.tsx +++ b/src/website/src/app/(solutions)/solutions/communities/page.tsx @@ -1,9 +1,9 @@ +import CommunitiesPage from '@/features/solutions/communities/CommunityPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import CommunitiesPage from '@/views/solutions/communities/CommunityPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.solutionsCommunities); diff --git a/src/website/src/app/(main)/solutions/kampala-study/page.tsx b/src/website/src/app/(solutions)/solutions/kampala-study/page.tsx similarity index 81% rename from src/website/src/app/(main)/solutions/kampala-study/page.tsx rename to src/website/src/app/(solutions)/solutions/kampala-study/page.tsx index b014b3e8f6..bc24794a92 100644 --- a/src/website/src/app/(main)/solutions/kampala-study/page.tsx +++ b/src/website/src/app/(solutions)/solutions/kampala-study/page.tsx @@ -1,9 +1,9 @@ +import KampalaStudyPage from '@/features/solutions/kampala-study/KampalaStudyPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import KampalaStudyPage from '@/views/solutions/kampala-study/KampalaStudyPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.solutionsKampalaStudy); diff --git a/src/website/src/app/(main)/solutions/layout.tsx b/src/website/src/app/(solutions)/solutions/layout.tsx similarity index 91% rename from src/website/src/app/(main)/solutions/layout.tsx rename to src/website/src/app/(solutions)/solutions/layout.tsx index 51a8d816fb..bc3057b500 100644 --- a/src/website/src/app/(main)/solutions/layout.tsx +++ b/src/website/src/app/(solutions)/solutions/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import React from 'react'; -import SolutionsContentLayout from '@/components/layouts/SolutionsContentLayout'; +import SolutionsContentLayout from '@/components/layout/SolutionsContentLayout'; export const metadata: Metadata = { title: { diff --git a/src/website/src/app/(main)/solutions/network-coverage/page.tsx b/src/website/src/app/(solutions)/solutions/network-coverage/page.tsx similarity index 80% rename from src/website/src/app/(main)/solutions/network-coverage/page.tsx rename to src/website/src/app/(solutions)/solutions/network-coverage/page.tsx index 3f4ee0d819..90597fc4a2 100644 --- a/src/website/src/app/(main)/solutions/network-coverage/page.tsx +++ b/src/website/src/app/(solutions)/solutions/network-coverage/page.tsx @@ -1,9 +1,9 @@ +import NetworkCoverageDeferred from '@/features/solutions/network-coverage/NetworkCoverageDeferred'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import NetworkCoverageDeferred from '@/views/solutions/NetworkCoverage/NetworkCoverageDeferred'; // Generate metadata using the centralized utility export const metadata = createMetadata( diff --git a/src/website/src/app/(main)/solutions/research/page.tsx b/src/website/src/app/(solutions)/solutions/research/page.tsx similarity index 84% rename from src/website/src/app/(main)/solutions/research/page.tsx rename to src/website/src/app/(solutions)/solutions/research/page.tsx index e75690ef22..558d36e223 100644 --- a/src/website/src/app/(main)/solutions/research/page.tsx +++ b/src/website/src/app/(solutions)/solutions/research/page.tsx @@ -1,9 +1,9 @@ +import ResearchPage from '@/features/solutions/research/ResearchPage'; import { generateMetadata as createMetadata, generateViewport, METADATA_CONFIGS, } from '@/lib/metadata'; -import ResearchPage from '@/views/solutions/research/ResearchPage'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.solutionsResearch); diff --git a/src/website/src/app/(main)/billboard/grid/[name]/page.tsx b/src/website/src/app/(utility)/billboard/grid/[name]/page.tsx similarity index 100% rename from src/website/src/app/(main)/billboard/grid/[name]/page.tsx rename to src/website/src/app/(utility)/billboard/grid/[name]/page.tsx diff --git a/src/website/src/app/(main)/billboard/interactive/page.tsx b/src/website/src/app/(utility)/billboard/interactive/page.tsx similarity index 100% rename from src/website/src/app/(main)/billboard/interactive/page.tsx rename to src/website/src/app/(utility)/billboard/interactive/page.tsx diff --git a/src/website/src/app/(main)/explore-data/layout.tsx b/src/website/src/app/(utility)/explore-data/layout.tsx similarity index 100% rename from src/website/src/app/(main)/explore-data/layout.tsx rename to src/website/src/app/(utility)/explore-data/layout.tsx diff --git a/src/website/src/app/(main)/explore-data/mobile-app/page.tsx b/src/website/src/app/(utility)/explore-data/mobile-app/page.tsx similarity index 100% rename from src/website/src/app/(main)/explore-data/mobile-app/page.tsx rename to src/website/src/app/(utility)/explore-data/mobile-app/page.tsx diff --git a/src/website/src/app/(main)/explore-data/page.tsx b/src/website/src/app/(utility)/explore-data/page.tsx similarity index 81% rename from src/website/src/app/(main)/explore-data/page.tsx rename to src/website/src/app/(utility)/explore-data/page.tsx index 24a8b5c257..7995d448d8 100644 --- a/src/website/src/app/(main)/explore-data/page.tsx +++ b/src/website/src/app/(utility)/explore-data/page.tsx @@ -1,8 +1,8 @@ +import ExplorePage from '@/features/explore-data/ExplorePage'; import { generateMetadata as createMetadata, METADATA_CONFIGS, } from '@/lib/metadata'; -import ExplorePage from '@/views/ExplorePage'; export const metadata = createMetadata(METADATA_CONFIGS.exploreData); diff --git a/src/website/src/app/africa-clean-air-forum/about/page.tsx b/src/website/src/app/africa-clean-air-forum/about/page.tsx deleted file mode 100644 index 2a2a04f3e9..0000000000 --- a/src/website/src/app/africa-clean-air-forum/about/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function AboutRedirect() { - // Redirect users from /africa-clean-air-forum/about to /africa-clean-air-forum - redirect('/africa-clean-air-forum'); -} diff --git a/src/website/src/app/error.tsx b/src/website/src/app/error.tsx index bb40ac9048..a6b495a2bb 100644 --- a/src/website/src/app/error.tsx +++ b/src/website/src/app/error.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useEffect } from 'react'; -import TopBanner from '@/components/layouts/TopBanner'; +import TopBanner from '@/components/layout/TopBanner'; import { CustomButton } from '@/components/ui'; export default function GlobalError({ diff --git a/src/website/src/app/globals.css b/src/website/src/app/globals.css index 665cb898fd..39cf4f7ce4 100644 --- a/src/website/src/app/globals.css +++ b/src/website/src/app/globals.css @@ -1,601 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;700&display=swap'); - -:root { - --font-inter: 'Inter', sans-serif; - - /* Zoom-responsive spacing variables */ - --billboard-spacing-xs: clamp(0.25rem, 0.5vw, 0.5rem); - --billboard-spacing-sm: clamp(0.5rem, 1vw, 0.75rem); - --billboard-spacing-md: clamp(0.75rem, 1.5vw, 1.25rem); - --billboard-spacing-lg: clamp(1rem, 2vw, 1.5rem); - --billboard-spacing-xl: clamp(1.5rem, 3vw, 2.5rem); - - /* Zoom-responsive font sizes */ - --billboard-font-xs: clamp(0.75rem, 1.2vw, 0.875rem); - --billboard-font-sm: clamp(0.875rem, 1.4vw, 1rem); - --billboard-font-base: clamp(1rem, 1.6vw, 1.125rem); - --billboard-font-lg: clamp(1.125rem, 1.8vw, 1.25rem); - --billboard-font-xl: clamp(1.25rem, 2vw, 1.5rem); - --billboard-font-2xl: clamp(1.5rem, 2.5vw, 2rem); - --billboard-font-3xl: clamp(2rem, 3.5vw, 3rem); - --billboard-font-4xl: clamp(3rem, 5vw, 4.5rem); -} - -body { - font-family: var(--font-inter); -} - -.font-dm-mono { - font-family: 'DM Mono', monospace; -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - .animate-fade-in { - animation: fade-in 0.4s ease-in; - } - - @keyframes devcon-top-ticker { - from { - transform: translateX(0); - } - to { - transform: translateX(-50%); - } - } - - .devcon-top-ticker-track { - animation: devcon-top-ticker 24s linear infinite; - } - - .devcon-top-ticker:hover .devcon-top-ticker-track { - animation-play-state: paused; - } - - @media (prefers-reduced-motion: reduce) { - .devcon-top-ticker-track { - animation: none; - transform: none; - } - } -} - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - } - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} - -/*# Legal page styles */ -.legal-page-content { - a { - color: blue; - text-decoration: underline; - } - - p { - margin-bottom: 1rem; - } - - ol { - list-style-type: decimal; - margin-left: 2rem; - - li { - margin-bottom: 0.5rem; - } - } - - ul { - list-style-type: disc; - margin-left: 2rem; - - li { - margin-bottom: 0.5rem; - } - } -} - -/* Customize the scrollbar for WebKit browsers */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: #f0f0f0; - border-radius: 8px; -} - -::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 255, 0.911); - border-radius: 8px; - border: 2px solid #f0f0f0; -} - -/* Firefox scrollbar styling */ -* { - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 255, 0.911) #f0f0f0; -} - -/* Internet Explorer and Edge support */ -body { - -ms-overflow-style: scrollbar; -} - -/* GitHub Ribbon Styles */ -.github-ribbon { - position: fixed; - top: 0; - z-index: 9999; - pointer-events: none; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; -} - -.github-ribbon.top-left { - left: 0; -} - -.github-ribbon.top-right { - right: 0; -} - -.github-ribbon-link { - display: inline-block; - padding: 8px 35px; - font-size: 12px; - font-weight: 600; - text-decoration: none; - text-align: center; - color: #ffffff; - background: #0882fc; /* AirQo primary blue */ - border: 0; - pointer-events: auto; - transform-origin: center; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - white-space: nowrap; - letter-spacing: 0.5px; - animation: slideInFromCorner 0.6s ease-out; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; -} - -.github-ribbon.top-left .github-ribbon-link { - transform: translate(-36px, 25px) rotate(-40deg); -} - -.github-ribbon.top-right .github-ribbon-link { - transform: translate(36px, 25px) rotate(40deg); -} - -.github-ribbon-link:hover { - background: #0066cc; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); -} - -.github-ribbon.top-left .github-ribbon-link:hover { - /* Removed transform effects, keeping only color change */ -} - -.github-ribbon.top-right .github-ribbon-link:hover { - /* Removed transform effects, keeping only color change */ -} - -.github-ribbon-link:focus { - outline: 2px solid #0a84ff; - outline-offset: 2px; -} - -.github-ribbon.top-left .github-ribbon-link:active { - transform: translate(-35px, 25px) rotate(-45deg) scale(0.98); -} - -.github-ribbon.top-right .github-ribbon-link:active { - transform: translate(35px, 25px) rotate(45deg) scale(0.98); -} - -/* Responsive behavior - hide on mobile */ -@media (max-width: 768px) { - .github-ribbon { - display: none; - } -} - -/* Accessibility improvements */ -@media (prefers-reduced-motion: reduce) { - .github-ribbon-link { - animation: none; - } - - .github-ribbon-link, - .github-ribbon-link:hover, - .github-ribbon-link:active { - transition: none; - } -} - -/* Animation on mount */ -@keyframes slideInFromCorner { - from { - opacity: 0; - transform: translate(-100px, -100px); - } - - to { - opacity: 1; - } -} - -/* Map custom marker styles */ -.custom-marker { - cursor: pointer; - position: relative; - transition: transform 0.2s ease-in-out; -} - -.custom-marker:hover { - z-index: 999 !important; -} - -.mapboxgl-popup-content { - padding: 0; - border-radius: 8px; -} - -.mapboxgl-popup-close-button { - font-size: 20px; - padding: 4px 8px; -} - -/* CRITICAL: Completely hide Google Translate banner - Global level */ -.goog-te-banner-frame, -.goog-te-banner-frame.skiptranslate, -iframe.goog-te-banner-frame, -.goog-te-banner, -#goog-gt-tt, -.goog-te-balloon-frame, -.VIpgJd-ZVi9od-ORHb, -.VIpgJd-ZVi9od-ORHb-OEVmcd, -.VIpgJd-ZVi9od-xl07Ob-OEVmcd, -body > .skiptranslate { - display: block !important; - visibility: hidden !important; - opacity: 0 !important; - width: 1px !important; - max-width: 1px !important; - height: 1px !important; - min-height: 0 !important; - overflow: hidden !important; - position: fixed !important; - top: -10000px !important; - left: -10000px !important; - z-index: -2147483648 !important; - pointer-events: none !important; -} - -/* Force body to stay in place when Google Translate is active */ -body, -body.translated-ltr, -body.translated-rtl { - top: 0 !important; - position: static !important; - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* Keep translate host mounted and measurable while visually hidden */ -.google-translate-host { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - border: 0; - white-space: nowrap; - pointer-events: none; -} - -/* Loading spinner styles */ -.app-route-loader { - position: fixed; - inset: 0; - z-index: 2147483647; - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - background: #ffffff; -} - -.app-inline-loader { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 120px; -} - -.app-route-loader-content { - display: flex; - align-items: center; - justify-content: center; -} - -.app-route-loader-spinner { - font-size: 45px; -} - -.app-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.loader { - color: #145dff; - font-size: 45px; - text-indent: -9999em; - overflow: hidden; - width: 1em; - height: 1em; - border-radius: 50%; - position: relative; - transform: translateZ(0); - animation: - mltShdSpin 1.7s infinite ease, - round 1.7s infinite ease; -} - -@keyframes mltShdSpin { - 0% { - box-shadow: - 0 -0.83em 0 -0.4em, - 0 -0.83em 0 -0.42em, - 0 -0.83em 0 -0.44em, - 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } - 5%, - 95% { - box-shadow: - 0 -0.83em 0 -0.4em, - 0 -0.83em 0 -0.42em, - 0 -0.83em 0 -0.44em, - 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } - 10%, - 59% { - box-shadow: - 0 -0.83em 0 -0.4em, - -0.087em -0.825em 0 -0.42em, - -0.173em -0.812em 0 -0.44em, - -0.256em -0.789em 0 -0.46em, - -0.297em -0.775em 0 -0.477em; - } - 20% { - box-shadow: - 0 -0.83em 0 -0.4em, - -0.338em -0.758em 0 -0.42em, - -0.555em -0.617em 0 -0.44em, - -0.671em -0.488em 0 -0.46em, - -0.749em -0.34em 0 -0.477em; - } - 38% { - box-shadow: - 0 -0.83em 0 -0.4em, - -0.377em -0.74em 0 -0.42em, - -0.645em -0.522em 0 -0.44em, - -0.775em -0.297em 0 -0.46em, - -0.82em -0.09em 0 -0.477em; - } - 100% { - box-shadow: - 0 -0.83em 0 -0.4em, - 0 -0.83em 0 -0.42em, - 0 -0.83em 0 -0.44em, - 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } -} - -@keyframes round { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* react-hot-toast z-index fix: ensure toasts appear above dialogs and headers */ -.react-hot-toast, -.react-hot-toast-container, -.react-hot-toast__container, -.react-hot-toast-wrapper, -.react-hot-toast__toast, -.toast, -.rh-toast { - z-index: 40000 !important; -} - -/* If Toaster renders container with data attributes, target common selectors too */ -[data-hot-toast-root] { - z-index: 40000 !important; -} - -/* Fade animations for translation loading */ -@keyframes fadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -.animate-fadeIn { - animation: fadeIn 0.3s ease-in-out; -} - -.animate-fadeOut { - animation: fadeOut 0.3s ease-in-out; -} - -/* Circular Progress Animation */ -@keyframes circular-progress { - 0% { - stroke-dashoffset: 100; - } - 100% { - stroke-dashoffset: 0; - } -} - -.animate-circular-progress { - animation: circular-progress 20s linear infinite; -} -/* Billboard Component Zoom-Responsive Optimizations */ -@layer components { - /* Ensure billboard containers maintain proper spacing at all zoom levels */ - [class*='billboard'] { - box-sizing: border-box; - } - - /* Smooth transitions for zoom changes */ - @media screen { - * { - transition-property: font-size, padding, margin, gap; - transition-duration: 0.1s; - transition-timing-function: ease-out; - } - - /* Disable transitions for specific cases to avoid performance issues */ - *:hover, - *:focus, - *:active { - transition-duration: 0s; - } - } - - /* Improve text rendering at various zoom levels */ - body { - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - - /* Prevent layout shift during zoom for fixed/absolute positioned elements */ - [data-billboard-portal='true'] { - will-change: transform; - backface-visibility: hidden; - } - - /* Ensure images scale properly with zoom */ - img { - max-width: 100%; - height: auto; - } - - /* Prevent overflow issues at high zoom levels */ - .overflow-hidden { - contain: layout style; - } -} +@import '@/styles/theme.css'; +@import '@/styles/typography.css'; +@import '@/styles/utilities.css'; diff --git a/src/website/src/app/layout.tsx b/src/website/src/app/layout.tsx index 2215a91d8f..524dfd9672 100644 --- a/src/website/src/app/layout.tsx +++ b/src/website/src/app/layout.tsx @@ -11,8 +11,8 @@ import ExternalLinkDecorator from '@/components/ExternalLinkDecorator'; import GoogleTranslate from '@/components/GoogleTranslate'; import GoogleAnalytics from '@/components/GoogleAnalytics'; import { ErrorBoundary } from '@/components/ui'; -import { ReduxDataProvider } from '@/context/ReduxDataProvider'; -import { QueryProvider } from '@/services/providers/QueryProvider'; +import { ReduxDataProvider } from '@/components/providers/ReduxProvider'; +import { QueryProvider } from '@/components/providers/QueryProvider'; import { generateViewport } from '@/lib/metadata'; import { getPrimarySiteUrl } from '@/lib/siteUrl'; diff --git a/src/website/src/app/loading.tsx b/src/website/src/app/loading.tsx deleted file mode 100644 index f6a7b57b26..0000000000 --- a/src/website/src/app/loading.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -const Loading = () => { - return ( -
-
- -

Loading...

-
-
- ); -}; - -export default Loading; diff --git a/src/website/src/app/not-found.tsx b/src/website/src/app/not-found.tsx index ef7d474afd..bcb2e57561 100644 --- a/src/website/src/app/not-found.tsx +++ b/src/website/src/app/not-found.tsx @@ -5,7 +5,7 @@ import { motion } from 'framer-motion'; import Image from 'next/image'; import Link from 'next/link'; -import TopBanner from '@/components/layouts/TopBanner'; +import TopBanner from '@/components/layout/TopBanner'; import { CustomButton } from '@/components/ui'; const NotFoundPage = () => { diff --git a/src/website/src/app/page.tsx b/src/website/src/app/page.tsx index 5227eecf5c..7e76be564a 100644 --- a/src/website/src/app/page.tsx +++ b/src/website/src/app/page.tsx @@ -4,7 +4,7 @@ import { METADATA_CONFIGS, } from '@/lib/metadata'; -import HomePage from './(main)/home/page'; +import HomePage from './(site)/home/page'; // Generate metadata using the centralized utility export const metadata = createMetadata(METADATA_CONFIGS.home); diff --git a/src/website/src/app/robots.ts b/src/website/src/app/robots.ts new file mode 100644 index 0000000000..9533d0d375 --- /dev/null +++ b/src/website/src/app/robots.ts @@ -0,0 +1,15 @@ +import { MetadataRoute } from 'next'; + +import { getPrimarySiteUrl } from '@/lib/siteUrl'; + +export default function robots(): MetadataRoute.Robots { + const siteUrl = getPrimarySiteUrl(); + + return { + rules: { + userAgent: '*', + allow: '/', + }, + sitemap: `${siteUrl}/sitemap.xml`, + }; +} diff --git a/src/website/src/app/robots.txt/route.ts b/src/website/src/app/robots.txt/route.ts deleted file mode 100644 index f1bdabb535..0000000000 --- a/src/website/src/app/robots.txt/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getPrimarySiteUrl } from '@/lib/siteUrl'; - -export async function GET() { - const siteUrl = getPrimarySiteUrl(); - - const robots = `User-agent: * -Allow: / - -# Prioritize African content and country pages -Allow: /uganda-air-quality -Allow: /kenya-air-quality -Allow: /nigeria-air-quality -Allow: /ghana-air-quality -Allow: /rwanda-air-quality -Allow: /tanzania-air-quality -Allow: /kampala-air-quality -Allow: /nairobi-air-quality -Allow: /lagos-air-quality -Allow: /accra-air-quality - -# High-crawl-rate pages -User-agent: Googlebot -Allow: / -Crawl-delay: 0.5 - -# Optimize for African search -User-agent: Google-Extended -Allow: / - -# Block access to admin or sensitive areas -Disallow: /api/ -Disallow: /_next/static/ -Disallow: /admin/ -Disallow: /*.json$ - -# Allow access to important assets -Allow: /assets/ -Allow: /favicon.ico -Allow: /icon.png -Allow: /apple-icon.png -Allow: /web-app-manifest-192x192.png - -# Sitemap locations (multiple for better discovery) -Sitemap: ${siteUrl}/sitemap.xml - -# Additional directives for better SEO -User-agent: Bingbot -Allow: / -Crawl-delay: 1 - -User-agent: Slurp -Crawl-delay: 1 - -User-agent: DuckDuckBot -Allow: / -Crawl-delay: 1 - -# Social media crawlers (for rich previews) -User-agent: facebookexternalhit -Allow: / - -User-agent: Twitterbot -Allow: / - -User-agent: LinkedInBot -Allow: / - -User-agent: WhatsApp -Allow: / - -# African-specific bots (if any) -User-agent: * -Crawl-delay: 2`; - - return new Response(robots, { - status: 200, - headers: { - 'Content-Type': 'text/plain', - 'Cache-Control': 'public, max-age=86400, s-maxage=86400', - }, - }); -} diff --git a/src/website/src/components/CookieConsent.tsx b/src/website/src/components/CookieConsent.tsx index df9f73f1a1..30bd975f45 100644 --- a/src/website/src/components/CookieConsent.tsx +++ b/src/website/src/components/CookieConsent.tsx @@ -12,7 +12,7 @@ import { hasAnalyticsConsent, setConsentPreferences, shouldShowConsentBanner, -} from '@/utils/cookieConsent'; +} from '@/lib/utils/cookieConsent'; /** * GDPR-compliant Cookie Consent Banner diff --git a/src/website/src/components/FloatingMiniBillboard.tsx b/src/website/src/components/FloatingMiniBillboard.tsx index 35d8cf8445..b8f8112f36 100644 --- a/src/website/src/components/FloatingMiniBillboard.tsx +++ b/src/website/src/components/FloatingMiniBillboard.tsx @@ -6,14 +6,14 @@ import { useEffect, useRef, useState } from 'react'; import AirQualityStatusIcon from '@/components/common/AirQualityStatusIcon'; import { hexToRgba } from '@/components/sections/AirQualityBillboard/utils'; -import type { Grid } from '@/types/grids'; import { AIR_QUALITY_INFO, categoryToLevel, formatName, getAirQualityCategory, getAirQualityColor, -} from '@/utils/airQuality'; +} from '@/lib/utils/airQuality'; +import type { Grid } from '@/types/grids'; const ROTATION_INTERVAL = 15000; // 15 seconds per grid @@ -81,7 +81,7 @@ export default function FloatingMiniBillboard({ color: '#ffffff', border: `1px solid ${hexToRgba(categoryColor, 0.9)}`, boxShadow: 'inset 0 -2px 0 rgba(0,0,0,0.06)', - fontFamily: '"Helvetica Neue", Arial, sans-serif', + fontFamily: '"Inter", system-ui, -apple-system, sans-serif', } as React.CSSProperties; } @@ -90,7 +90,7 @@ export default function FloatingMiniBillboard({ backgroundColor: hexToRgba(categoryColor, 0.14), color: categoryColor, border: `1px solid ${hexToRgba(categoryColor, 0.16)}`, - fontFamily: '"Times New Roman", Times, serif', + fontFamily: '"Inter", system-ui, -apple-system, sans-serif', } as React.CSSProperties; })(); diff --git a/src/website/src/components/FloatingMiniBillboardWrapper.tsx b/src/website/src/components/FloatingMiniBillboardWrapper.tsx index 663fd34dda..1a176e08ce 100644 --- a/src/website/src/components/FloatingMiniBillboardWrapper.tsx +++ b/src/website/src/components/FloatingMiniBillboardWrapper.tsx @@ -3,7 +3,8 @@ import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { externalService, gridsService } from '@/services/apiService'; +import { externalService } from '@/services/external'; +import { gridsService } from '@/services/website'; import { Grid } from '@/types/grids'; import FloatingMiniBillboard from './FloatingMiniBillboard'; diff --git a/src/website/src/components/GoogleTranslate.tsx b/src/website/src/components/GoogleTranslate.tsx index 4b1b145987..e0799e1d7d 100644 --- a/src/website/src/components/GoogleTranslate.tsx +++ b/src/website/src/components/GoogleTranslate.tsx @@ -8,8 +8,8 @@ import { GOOGLE_TRANSLATE_LOAD_EVENT, isGoogleTranslationActive, normalizeGoogleLanguageCode, -} from '@/utils/googleTranslate'; -import { languages } from '@/utils/languages'; +} from '@/lib/utils/googleTranslate'; +import { languages } from '@/lib/utils/languages'; declare global { interface Window { diff --git a/src/website/src/components/LanguageFlag.tsx b/src/website/src/components/LanguageFlag.tsx index 37e817e812..6267b2c5d9 100644 --- a/src/website/src/components/LanguageFlag.tsx +++ b/src/website/src/components/LanguageFlag.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import React, { useEffect, useMemo, useState } from 'react'; -import { getFlagUrl } from '@/utils/languages'; +import { getFlagUrl } from '@/lib/utils/languages'; interface LanguageFlagProps { flag: string; diff --git a/src/website/src/components/ui/MemberCard.tsx b/src/website/src/components/cards/MemberCard.tsx similarity index 95% rename from src/website/src/components/ui/MemberCard.tsx rename to src/website/src/components/cards/MemberCard.tsx index 49e2ffad2f..01cf8e378b 100644 --- a/src/website/src/components/ui/MemberCard.tsx +++ b/src/website/src/components/cards/MemberCard.tsx @@ -1,279 +1,278 @@ -'use client'; - -import PlaceholderImage from '@public/assets/images/placeholder.webp'; -import DOMPurify from 'dompurify'; -import Image from 'next/image'; -import React, { useState } from 'react'; -import { FaLinkedinIn, FaTwitter } from 'react-icons/fa'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, - Divider, -} from '@/components/ui'; -import { cn } from '@/lib/utils'; -import { convertDeltaToHtml } from '@/utils/quillUtils'; - -import AvatarFallback from './AvatarFallback'; - -// Define types for member data -interface Member { - // list response fields (may vary between board/team) - id?: string | number; - public_identifier?: string; - api_url?: string; - name?: string; - title?: string; // Generic title field - role?: string; // Team members might use this - position?: string; // Board members use this - picture_url?: string; - picture?: string; - image?: string; // Another common field name - bio?: string; - about?: string; - bio_description?: string; - description?: string; - descriptions?: { id?: number; description?: string }[]; - twitter?: string | null; - linked_in?: string | null; - linkedin?: string | null; // Alternative field name -} - -interface MemberCardProps { - member: Member; - btnText?: string; - cardClassName?: string; -} - -const MemberCard: React.FC = ({ - member, - btnText = 'Read Bio', - cardClassName, -}) => { - // Use the member data directly (no additional fetching needed) - const [imageError, setImageError] = useState(false); - const [dialogImageError, setDialogImageError] = useState(false); - - const renderContent = (content?: string) => { - if (!content) return ''; - - const html = convertDeltaToHtml(content); - if (html && html.trim()) { - return DOMPurify.sanitize(html); - } - - // Fallback to plain text if conversion returns empty - if (typeof content === 'string') { - const raw = content.trim(); - return raw ? DOMPurify.sanitize(`

${raw}

`) : ''; - } - return ''; - }; - - return ( - - {/* TRIGGER (Card) */} - -
- {/* - Extremely tall image container (3050px), with rounded edges. - Also applying 'mt-[-20px]' to push the container up slightly. - */} -
- {imageError ? ( - - ) : ( - {member.name setImageError(true)} - /> - )} -
- - {/* Name and Title */} -
-

- {member.name} -

-

- {member.title || member.role || member.position} -

-
- - {/* Read Bio Button */} - {btnText && ( - - {member.descriptions?.length || - member.bio || - member.about || - member.bio_description || - member.description - ? btnText - : 'View Profile'} - - )} - - {/* Social Icons */} -
- {member.twitter && ( - - - - )} - {(member.linked_in || member.linkedin) && ( - - - - )} -
-
-
- - {/* DIALOG CONTENT */} - -
- {/* Header Section */} - - - {member.name} - -

- {member.title || member.role || member.position} -

- - {/* Optional Social Media Icons in the dialog header */} -
- {member.twitter && ( - - - - )} - {(member.linked_in || member.linkedin) && ( - - - - )} -
-
- - - - {/* Main Content: Image + Bio */} -
- {/* Dialog Image */} -
- {dialogImageError ? ( - - ) : ( - {member.name setDialogImageError(true)} - /> - )} -
- - {/* Description with scroll if content is long */} -
- - {member.descriptions?.length ? ( - member.descriptions.map((desc: any, idx: number) => ( -

- {desc.description} -

- )) - ) : !( - member.bio || - member.about || - member.bio_description || - member.description - ) ? ( -

Bio is not available.

- ) : null} - - {/* Check multiple possible bio fields as fallback */} - {(member.bio || - member.about || - member.bio_description || - member.description) && ( -
- )} - -
-
-
- -
- ); -}; - -export default MemberCard; +'use client'; + +import PlaceholderImage from '@public/assets/images/placeholder.webp'; +import DOMPurify from 'dompurify'; +import Image from 'next/image'; +import React, { useState } from 'react'; +import { FaLinkedinIn, FaTwitter } from 'react-icons/fa'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + Divider, +} from '@/components/ui'; +import AvatarFallback from '@/components/ui/AvatarFallback'; +import { cn } from '@/lib/utils'; +import { convertDeltaToHtml } from '@/lib/utils/quillUtils'; + +// Define types for member data +interface Member { + // list response fields (may vary between board/team) + id?: string | number; + public_identifier?: string; + api_url?: string; + name?: string; + title?: string; // Generic title field + role?: string; // Team members might use this + position?: string; // Board members use this + picture_url?: string; + picture?: string; + image?: string; // Another common field name + bio?: string; + about?: string; + bio_description?: string; + description?: string; + descriptions?: { id?: number; description?: string }[]; + twitter?: string | null; + linked_in?: string | null; + linkedin?: string | null; // Alternative field name +} + +interface MemberCardProps { + member: Member; + btnText?: string; + cardClassName?: string; +} + +const MemberCard: React.FC = ({ + member, + btnText = 'Read Bio', + cardClassName, +}) => { + // Use the member data directly (no additional fetching needed) + const [imageError, setImageError] = useState(false); + const [dialogImageError, setDialogImageError] = useState(false); + + const renderContent = (content?: string) => { + if (!content) return ''; + + const html = convertDeltaToHtml(content); + if (html && html.trim()) { + return DOMPurify.sanitize(html); + } + + // Fallback to plain text if conversion returns empty + if (typeof content === 'string') { + const raw = content.trim(); + return raw ? DOMPurify.sanitize(`

${raw}

`) : ''; + } + return ''; + }; + + return ( + + {/* TRIGGER (Card) */} + +
+ {/* + Extremely tall image container (3050px), with rounded edges. + Also applying 'mt-[-20px]' to push the container up slightly. + */} +
+ {imageError ? ( + + ) : ( + {member.name setImageError(true)} + /> + )} +
+ + {/* Name and Title */} +
+

+ {member.name} +

+

+ {member.title || member.role || member.position} +

+
+ + {/* Read Bio Button */} + {btnText && ( + + {member.descriptions?.length || + member.bio || + member.about || + member.bio_description || + member.description + ? btnText + : 'View Profile'} + + )} + + {/* Social Icons */} +
+ {member.twitter && ( + + + + )} + {(member.linked_in || member.linkedin) && ( + + + + )} +
+
+
+ + {/* DIALOG CONTENT */} + +
+ {/* Header Section */} + + + {member.name} + +

+ {member.title || member.role || member.position} +

+ + {/* Optional Social Media Icons in the dialog header */} +
+ {member.twitter && ( + + + + )} + {(member.linked_in || member.linkedin) && ( + + + + )} +
+
+ + + + {/* Main Content: Image + Bio */} +
+ {/* Dialog Image */} +
+ {dialogImageError ? ( + + ) : ( + {member.name setDialogImageError(true)} + /> + )} +
+ + {/* Description with scroll if content is long */} +
+ + {member.descriptions?.length ? ( + member.descriptions.map((desc: any, idx: number) => ( +

+ {desc.description} +

+ )) + ) : !( + member.bio || + member.about || + member.bio_description || + member.description + ) ? ( +

Bio is not available.

+ ) : null} + + {/* Check multiple possible bio fields as fallback */} + {(member.bio || + member.about || + member.bio_description || + member.description) && ( +
+ )} + +
+
+
+ +
+ ); +}; + +export default MemberCard; diff --git a/src/website/src/components/common/AirQualityStatusIcon.tsx b/src/website/src/components/common/AirQualityStatusIcon.tsx index c8745045bc..7be708441c 100644 --- a/src/website/src/components/common/AirQualityStatusIcon.tsx +++ b/src/website/src/components/common/AirQualityStatusIcon.tsx @@ -1,6 +1,6 @@ import type { SVGProps } from 'react'; -import type { AirQualityLevel } from '@/utils/airQuality'; +import type { AirQualityLevel } from '@/lib/utils/airQuality'; interface AirQualityStatusIconProps extends SVGProps { status: AirQualityLevel; diff --git a/src/website/src/components/dialogs/EngagementDialog.tsx b/src/website/src/components/dialogs/EngagementDialog.tsx index 6f5d99df31..b8e5c11336 100644 --- a/src/website/src/components/dialogs/EngagementDialog.tsx +++ b/src/website/src/components/dialogs/EngagementDialog.tsx @@ -10,9 +10,10 @@ import { DialogDescription, DialogTitle, } from '@/components/ui/dialog'; -import mainConfig from '@/configs/mainConfigs'; +import mainConfig from '@/config/site.config'; import { useDispatch, useSelector } from '@/hooks'; -import { externalService } from '@/services/apiService'; +import { externalService } from '@/services/external'; +import type { RootState } from '@/store'; import { closeModal } from '@/store/slices/modalSlice'; import { trackEvent } from '../GoogleAnalytics'; @@ -111,7 +112,7 @@ const errorVariants = { const EngagementDialog = () => { const dispatch = useDispatch(); - const isOpen = useSelector((state: any) => state.modal.isOpen); + const isOpen = useSelector((state: RootState) => state.modal.isOpen); const [activeSection, setActiveSection] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); const [submissionSuccess, setSubmissionSuccess] = useState(false); @@ -330,14 +331,14 @@ const EngagementDialog = () => {