diff --git a/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml b/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml
index e077e5d528..0f944d8e03 100644
--- a/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml
+++ b/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-stage-docs
- tag: stage-60a6107f-1780390970
+ tag: stage-6acf7cbe-1780867781
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml b/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml
index 49527837c3..5cf12da782 100644
--- a/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml
+++ b/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml
@@ -1,7 +1,7 @@
replicaCount: 1
image:
repository: airqoacr.azurecr.io/airqo-stage-next-platform
- tag: stage-6472f849-1780570227
+ tag: stage-67057238-1780943479
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml
index 37ca8d2cec..cb0667a4e8 100644
--- a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml
+++ b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-vertex
- tag: prod-1786c96c-1780691136
+ tag: prod-a3a8f47b-1780838720
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml
index 0344355133..cbae978709 100644
--- a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml
+++ b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-stage-vertex
- tag: stage-d3209b04-1780831845
+ tag: stage-b98de37b-1780853334
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/src/docs-website/docs/api/for-cities/intro.md b/src/docs-website/docs/api/for-cities/intro.md
index 7a58240d4b..a870eb8d17 100644
--- a/src/docs-website/docs/api/for-cities/intro.md
+++ b/src/docs-website/docs/api/for-cities/intro.md
@@ -41,13 +41,15 @@ If a device belongs to an organisation's private Cohort, it will **not** appear
| Historical measurements | `POST /api/v3/public/analytics/data-download` |
| Spatial heatmap (city) | `GET /api/v2/spatial/heatmaps/{GRID_ID}?token={SECRET_TOKEN}` |
| All available heatmaps | `GET /api/v2/spatial/heatmaps?token={SECRET_TOKEN}` |
+| Daily forecasts | `GET /api/v2/predict/daily-forecasting/?grid_id={GRID_ID}&token={SECRET_TOKEN}` |
+| Hourly forecasts | `GET /api/v2/predict/hourly-forecasting/?grid_id={GRID_ID}&token={SECRET_TOKEN}` |
---
## Finding your Grid ID
-- **Browse the metadata endpoints** at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata)
-- **Filter by location** using the `admin_level` query parameter (country, province, or city)
+- **Browse the [Metadata API](../reference/metadata.md#grid-summary-with-site-details):** call `GET /api/v2/devices/grids/summary?admin_level=city` to list all city-level grids with their site details
+- **Filter by location** using the `admin_level` query parameter (`country`, `province`, `city`, `district`)
- **Example:** The Grid ID for Nairobi is `64b7ac8fd7249f0029feca80`
---
@@ -60,7 +62,7 @@ If a device belongs to an organisation's private Cohort, it will **not** appear
| Spatial heatmaps | ✅ Free | Base64 PNG with boundary coordinates |
| Historical calibrated data | ✅ Standard+ | Via Analytics API |
| Raw sensor readings | ✅ Standard+ | Via Analytics API |
-| Air quality forecasts | ✅ Premium | Via site or device ID from your Grid |
+| Air quality forecasts | ✅ Premium | Via `grid_id` or per-site `site_id` — see [Forecast API](../forecasts/overview.md) |
---
@@ -79,4 +81,5 @@ City authorities already using Grid ID access:
- [Fetch recent measurements →](./recent-measurements.md)
- [Access historical data →](./historical-data.md)
- [Generate spatial heatmaps →](./spatial-heatmaps.md)
+- [Get air quality forecasts →](../forecasts/overview.md)
- [Set up your account →](../getting-started/authentication.md)
diff --git a/src/docs-website/docs/api/for-partners/intro.md b/src/docs-website/docs/api/for-partners/intro.md
index bdd89c8146..570478ab68 100644
--- a/src/docs-website/docs/api/for-partners/intro.md
+++ b/src/docs-website/docs/api/for-partners/intro.md
@@ -33,12 +33,14 @@ At this time, Cohorts are created and configured by the AirQo team on behalf of
## Your Cohort ID endpoint structure
-Once you have your Cohort ID, two endpoints cover your primary data needs:
+Once you have your Cohort ID, these endpoints cover your primary data needs:
| Data | Endpoint |
|------|----------|
| Recent measurements (last 7 days) | `GET /api/v2/devices/measurements/cohorts/{COHORT_ID}?token={SECRET_TOKEN}` |
| Historical measurements | `POST /api/v3/public/analytics/data-download` |
+| Daily forecasts | `GET /api/v2/predict/daily-forecasting/?cohort_id={COHORT_ID}&token={SECRET_TOKEN}` |
+| Hourly forecasts | `GET /api/v2/predict/hourly-forecasting/?cohort_id={COHORT_ID}&token={SECRET_TOKEN}` |
---
@@ -52,7 +54,7 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto
## Getting your Cohort ID
-- **Public Cohorts:** Browse the metadata endpoints at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata)
+- **Public Cohorts:** Use the [Metadata API](../reference/metadata.md#list-all-public-cohorts) — call `GET /api/v2/devices/metadata/cohorts` to list all publicly visible cohorts
- **Private Cohorts:** Contact [support@airqo.net](mailto:support@airqo.net) — your Cohort ID will be shared securely
---
@@ -64,7 +66,7 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto
| Recent hourly measurements | ✅ Free | Last 7 days |
| Historical calibrated data | ✅ Standard+ | Via Analytics API |
| Raw sensor readings | ✅ Standard+ | Via Analytics API |
-| Air quality forecasts | ❌ | Not currently available per Cohort ID |
+| Air quality forecasts | ✅ Premium | Via `cohort_id` on the [Forecast API](../forecasts/overview.md) |
| Spatial heatmaps | ❌ | Heatmaps use Grid ID |
---
@@ -73,4 +75,5 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto
- [Fetch recent measurements →](./recent-measurements.md)
- [Access historical data →](./historical-data.md)
+- [Get air quality forecasts →](../forecasts/overview.md)
- [Set up your account →](../getting-started/authentication.md)
diff --git a/src/docs-website/docs/api/forecasts/overview.md b/src/docs-website/docs/api/forecasts/overview.md
index d89711bc38..357c8b4f98 100644
--- a/src/docs-website/docs/api/forecasts/overview.md
+++ b/src/docs-website/docs/api/forecasts/overview.md
@@ -5,7 +5,7 @@ sidebar_label: Forecast API
# Forecast API
-Get hourly and daily air quality predictions for up to 7 days ahead, with health recommendations based on predicted pollution levels.
+The Predict API provides site-level PM2.5 air quality forecasts generated by AirQo forecasting models. The service supports both daily forecasts (7-day horizon) and hourly forecasts (up to 240 hours ahead by default). Forecasts can be retrieved for individual monitoring sites, grids, or cohorts of monitoring sites.
:::info Tier requirement
Forecasts require a **Premium Tier** subscription.
@@ -13,242 +13,308 @@ Forecasts require a **Premium Tier** subscription.
---
-## What you get
-
-- **7-day hourly forecasts** — one prediction per hour for the next 168 hours
-- **7-day daily forecasts** — one prediction per day for the next 7 days
-- **Health tips** — contextual recommendations based on predicted PM2.5 levels
-- **Forecast metadata** — model version, generation timestamp, and location details
-
-Forecasts are available for any active monitoring site or device. They are regenerated every 6 hours (00:00, 06:00, 12:00, 18:00 UTC).
-
----
-
-## Endpoints
-
-### Hourly forecasts
-
-#### By Device ID
+## Daily Forecasting Endpoint
-```
-GET https://api.airqo.net/api/v2/predict/hourly-forecast?device_id={DEVICE_ID}&token={SECRET_TOKEN}
+```http
+GET /api/v2/predict/daily-forecasting/
```
-#### By Site ID
+Returns PM2.5 forecasts for the next 7 days, grouped by monitoring site.
-```
-GET https://api.airqo.net/api/v2/predict/hourly-forecast?site_id={SITE_ID}&token={SECRET_TOKEN}
-```
-
-### Daily forecasts
+### Supported Parameters
-#### By Device ID
+| Parameter | Description |
+|-----------|-------------|
+| `site_id` | Forecast for a single site |
+| `grid_id` | Forecasts for all sites within a grid |
+| `cohort_id` | Forecasts for all sites within a cohort |
+| `scope` | Optional — `"grid"` or `"cohort"` to disambiguate when a scope ID could match either type |
-```
-GET https://api.airqo.net/api/v2/predict/daily-forecast?device_id={DEVICE_ID}&token={SECRET_TOKEN}
-```
+Only one of `site_id`, `grid_id`, or `cohort_id` may be provided per request.
-#### By Site ID
+### Response Structure
-```
-GET https://api.airqo.net/api/v2/predict/daily-forecast?site_id={SITE_ID}&token={SECRET_TOKEN}
+```json
+{
+ "success": true,
+ "data": {
+ "start_date": "2026-06-03",
+ "end_date": "2026-06-09",
+ "days": 7,
+ "total": 1,
+ "units": {
+ "pm2_5": "ug/m3",
+ "air_temperature": "degC",
+ "relative_humidity": "%",
+ "air_pressure_at_sea_level": "hPa",
+ "precipitation_amount": "mm",
+ "cloud_area_fraction": "%",
+ "wind_speed": "m/s",
+ "wind_from_direction": "compass",
+ "forecast_confidence": "%"
+ },
+ "forecasts": [
+ {
+ "site_details": {
+ "site_id": "64f7b3e8c9d25a0013f2d456",
+ "site_name": "Kampala Central",
+ "site_latitude": 0.3476,
+ "site_longitude": 32.5825
+ },
+ "forecasts": [
+ {
+ "date": "2026-06-03",
+ "forecast": {
+ "pm2_5_mean": 28.4,
+ "pm2_5_low": 22.1,
+ "pm2_5_high": 34.8,
+ "pm2_5_min": 19.0,
+ "pm2_5_max": 37.2,
+ "forecast_confidence": 84.5
+ },
+ "aqi": {
+ "category": "Moderate",
+ "label": "Air quality is acceptable for most people.",
+ "trend_message": "Air pollution may increase over the next 24 hours."
+ },
+ "met": {
+ "air_temperature": 24.7,
+ "relative_humidity": 72.1,
+ "air_pressure_at_sea_level": 1012.3,
+ "precipitation_amount": 0.0,
+ "cloud_area_fraction": 45.0,
+ "wind_speed": 3.2,
+ "wind_from_direction": "NE"
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
```
---
-## Query parameters
+## Hourly Forecasting Endpoint
-| Parameter | Type | Required | Description |
-|-----------|------|----------|-------------|
-| `device_id` | string | Conditional | Device identifier (use either `device_id` or `site_id`) |
-| `site_id` | string | Conditional | Site identifier (use either `site_id` or `device_id`) |
-| `token` | string | Yes | Your SECRET TOKEN |
+```http
+GET /api/v2/predict/hourly-forecasting/
+```
----
+Returns hourly PM2.5 forecasts beginning from the current forecast hour.
-## Example requests
+### Supported Parameters
-```bash
-# Hourly forecast by site ID
-curl -X GET \
- "https://api.airqo.net/api/v2/predict/hourly-forecast?site_id=64f7b3e8c9d25a0013f2d456&token=YOUR_SECRET_TOKEN"
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `site_id` | string | Forecast for a single site |
+| `grid_id` | string | Forecasts for all sites within a grid |
+| `cohort_id` | string | Forecasts for all sites within a cohort |
+| `scope` | string | Optional — `"grid"` or `"cohort"` to disambiguate |
+| `page` | integer | Page number (default: `1`) |
+| `limit` | integer | Results per page (default: `10`, max: `100`) |
-# Daily forecast by device ID
-curl -X GET \
- "https://api.airqo.net/api/v2/predict/daily-forecast?device_id=65c8d4a2f1b45c0012a3e789&token=YOUR_SECRET_TOKEN"
-```
+Only one of `site_id`, `grid_id`, or `cohort_id` may be provided per request.
-**JavaScript — hourly forecast:**
+### Key Features
-```js
-async function getHourlyForecast(siteId, token) {
- const url = `https://api.airqo.net/api/v2/predict/hourly-forecast?site_id=${siteId}&token=${token}`;
- const response = await fetch(url);
- const data = await response.json();
+- Default forecast horizon: **240 hours (10 days)**
+- Pagination support across all sites in a grid or cohort
+- AQI category, advisory label, and trend message included
+- Uncertainty range via `pm2_5_q10` and `pm2_5_q90` percentiles
+- Meteorological context included per hour
- if (data.success) {
- console.log(`Retrieved ${data.forecasts.length} hourly forecasts`);
+### Response Structure
- // Show next 24 hours
- data.forecasts.slice(0, 24).forEach(forecast => {
- console.log(`${forecast.time}: PM2.5 = ${forecast.pm2_5} μg/m³`);
- console.log(`Tips: ${forecast.health_tips.join(' | ')}`);
- });
+```json
+{
+ "success": true,
+ "data": {
+ "start_timestamp": "2026-06-07T10:00:00+00:00",
+ "end_timestamp": "2026-06-17T10:00:00+00:00",
+ "hours": 240,
+ "total": 10,
+ "page": 1,
+ "pages": 24,
+ "units": {
+ "pm2_5": "ug/m3",
+ "air_temperature": "degC",
+ "relative_humidity": "%",
+ "air_pressure_at_sea_level": "hPa",
+ "precipitation_amount": "mm",
+ "cloud_area_fraction": "%",
+ "wind_speed": "m/s",
+ "wind_from_direction": "compass",
+ "forecast_confidence": "%"
+ },
+ "forecasts": [
+ {
+ "site_details": {
+ "site_id": "64f7b3e8c9d25a0013f2d456",
+ "site_name": "Kampala Central",
+ "site_latitude": 0.3476,
+ "site_longitude": 32.5825
+ },
+ "forecasts": [
+ {
+ "timestamp": "2026-06-07T10:00:00+00:00",
+ "forecast": {
+ "pm2_5_mean": 28.4,
+ "pm2_5_q10": 20.1,
+ "pm2_5_q90": 36.2,
+ "forecast_confidence": 84.5
+ },
+ "aqi": {
+ "category": "Moderate",
+ "label": "Air quality is acceptable for most people.",
+ "trend_message": "Air pollution may increase over the next hour."
+ },
+ "met": {
+ "air_temperature": 24.7,
+ "relative_humidity": 72.1,
+ "air_pressure_at_sea_level": 1012.3,
+ "precipitation_amount": 0.0,
+ "cloud_area_fraction": 45.0,
+ "wind_speed": 3.2,
+ "wind_from_direction": "NE"
+ }
+ }
+ ]
+ }
+ ]
}
-
- return data;
}
-
-getHourlyForecast('64f7b3e8c9d25a0013f2d456', 'YOUR_SECRET_TOKEN');
```
-**Python — daily forecast:**
-
-```python
-import requests
-from datetime import datetime
-
-def get_daily_forecast(device_id, token):
- response = requests.get(
- "https://api.airqo.net/api/v2/predict/daily-forecast",
- params={"device_id": device_id, "token": token}
- )
- data = response.json()
+---
- if data['success']:
- for forecast in data['forecasts']:
- date = datetime.fromisoformat(forecast['time'].replace('+00:00', ''))
- print(f"\n{date.strftime('%Y-%m-%d')}: PM2.5 = {forecast['pm2_5']} μg/m³")
- for tip in forecast['health_tips']:
- print(f" • {tip}")
+## Scope (Grid and Cohort) Forecasting
- return data
+When grid-based or cohort-based forecasting is requested, the API supports aggregated forecast retrieval through:
-get_daily_forecast('65c8d4a2f1b45c0012a3e789', 'YOUR_SECRET_TOKEN')
+```http
+GET /api/v2/predict/hourly-forecasting/
+GET /api/v2/predict/daily-forecasting/
```
----
+The `` path parameter accepts a grid ID or cohort ID. Use the optional `scope` query parameter (`"grid"` or `"cohort"`) if the ID is ambiguous.
-## Example response
+### Scope Metadata Returned
+
+The fields below are returned inside the top-level `data` object (`{ "success": true, "data": { ... } }`).
```json
{
- "success": true,
- "message": "Forecasts retrieved successfully",
+ "scope": {
+ "type": "grid",
+ "id": "64b7ac8fd7249f0029feca80",
+ "grid_id": "64b7ac8fd7249f0029feca80"
+ },
+ "sites_count": 3,
+ "sites_with_forecasts_count": 2,
+ "site_names": [
+ "Kampala Central",
+ "Makerere"
+ ],
"forecasts": [
{
- "time": "2025-09-29T14:00:00+00:00",
- "pm2_5": 24.5,
- "health_tips": [
- "Air quality is acceptable for most people",
- "Unusually sensitive individuals should consider reducing prolonged outdoor exertion"
- ]
- },
- {
- "time": "2025-09-29T15:00:00+00:00",
- "pm2_5": 28.3,
- "health_tips": [
- "Air quality is acceptable for most people",
- "Consider reducing outdoor activities if you experience symptoms"
+ "site_details": {
+ "site_id": "68fca1da53b8b4001372887f",
+ "site_name": "Buyala 1 Mpigi",
+ "site_latitude": 0.313465,
+ "site_longitude": 32.405833
+ },
+ "start_date": "2026-06-03",
+ "end_date": "2026-06-09",
+ "days": 7,
+ "total": 7,
+ "forecasts": [
+ {
+ "date": "2026-06-03",
+ "forecast": {
+ "pm2_5_mean": 24.1,
+ "pm2_5_low": 18.0,
+ "pm2_5_high": 29.5
+ }
+ }
]
}
- ],
- "forecast_metadata": {
- "model_version": "v2.3",
- "generated_at": "2025-09-29T12:00:00+00:00",
- "location": {
- "site_id": "64f7b3e8c9d25a0013f2d456",
- "device_id": "65c8d4a2f1b45c0012a3e789",
- "latitude": 0.3476,
- "longitude": 32.5825
- },
- "forecast_horizon_hours": 168
- }
+ ]
}
```
---
-## Response fields
-
-### Forecast object
-
-| Field | Description |
-|-------|-------------|
-| `time` | Forecast timestamp (ISO 8601 with timezone) |
-| `pm2_5` | Predicted PM2.5 in μg/m³ |
-| `health_tips` | Array of health recommendations for the predicted air quality level |
-
-### `forecast_metadata` object
-
-| Field | Description |
-|-------|-------------|
-| `model_version` | Version of the forecasting model |
-| `generated_at` | When this forecast was created |
-| `location.site_id` | Site the forecast applies to |
-| `location.device_id` | Device the forecast applies to |
-| `forecast_horizon_hours` | Total hours covered (168 = 7 days) |
+## Response Field Reference
+
+### Daily forecast item
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `date` | string | Forecast date (ISO 8601 date) |
+| `forecast.pm2_5_mean` | number | Mean PM2.5 forecast in µg/m³ |
+| `forecast.pm2_5_low` | number | Lower bound of forecast range |
+| `forecast.pm2_5_high` | number | Upper bound of forecast range |
+| `forecast.pm2_5_min` | number | Minimum predicted value across the day |
+| `forecast.pm2_5_max` | number | Maximum predicted value across the day |
+| `forecast.forecast_confidence` | number | Model confidence (0–100%) |
+| `aqi.category` | string | AQI category name (see AQI Categories below) |
+| `aqi.label` | string | Health advisory for this AQI level |
+| `aqi.trend_message` | string | Expected direction of PM2.5 over the next period |
+| `met.air_temperature` | number | Air temperature in °C |
+| `met.relative_humidity` | number | Relative humidity in % |
+| `met.air_pressure_at_sea_level` | number | Atmospheric pressure in hPa |
+| `met.precipitation_amount` | number | Expected rainfall in mm |
+| `met.cloud_area_fraction` | number | Cloud cover in % |
+| `met.wind_speed` | number | Wind speed in m/s |
+| `met.wind_from_direction` | string | Wind direction as compass point (e.g. `"NE"`, `"SW"`) |
+
+### Hourly forecast item
+
+Same as daily, with the following differences:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `timestamp` | string | Forecast hour (ISO 8601 datetime with timezone) |
+| `forecast.pm2_5_q10` | number | 10th percentile — lower uncertainty bound |
+| `forecast.pm2_5_q90` | number | 90th percentile — upper uncertainty bound |
+
+`pm2_5_low` / `pm2_5_high` are not included in hourly items; use `pm2_5_q10` / `pm2_5_q90` instead.
---
-## Error responses
+## AQI Categories
-### 401 Unauthorised — invalid token
+Forecasts include an AQI category derived from the predicted PM2.5 mean value.
-```json
-{
- "success": false,
- "message": "Invalid authentication",
- "error": "Unauthorized"
-}
-```
+| Category | PM2.5 range (µg/m³) | Color |
+|----------|---------------------|-------|
+| Good | 0.0 – 9.0 | Green (`#00e400`) |
+| Moderate | 9.1 – 35.4 | Yellow (`#ffff00`) |
+| Unhealthy for Sensitive Groups | 35.5 – 55.4 | Orange (`#ff7e00`) |
+| Unhealthy | 55.5 – 125.4 | Red (`#ff0000`) |
+| Very Unhealthy | 125.5 – 225.4 | Purple (`#8f3f97`) |
+| Hazardous | 225.5+ | Maroon (`#7e0023`) |
-### 403 Forbidden — not on Premium Tier
+---
-```json
-{
- "success": false,
- "message": "Forecast access requires Premium Tier subscription",
- "error": "Forbidden",
- "upgrade_url": "https://analytics.airqo.net/account/subscription"
-}
-```
+## Validation Error
-### 404 Not Found — invalid ID
+Returned when more than one of `site_id`, `grid_id`, or `cohort_id` is provided in the same request.
```json
{
"success": false,
- "message": "Device or site not found",
- "error": "Not Found"
+ "message": "Please specify only one of site_id, grid_id, or cohort_id.",
+ "data": {
+ "forecasts": []
+ }
}
```
---
-## Best practices
-
-- **Cache forecast results** — forecasts update every 6 hours. Caching for 1–2 hours reduces API calls with minimal freshness trade-off.
-- **Check `generated_at`** — use this to tell users how recent the forecast is.
-- **Always display health tips** — they are the most user-actionable part of the forecast.
-- **Prefer near-term data** — the first 48 hours are more accurate than days 4–7.
-- **Implement graceful fallback** — if the forecast call fails, display the most recent historical reading instead.
-
----
-
-## Forecast availability
-
-| Detail | Value |
-|--------|-------|
-| Update frequency | Every 6 hours (00:00, 06:00, 12:00, 18:00 UTC) |
-| Hourly horizon | 168 hours (7 days) |
-| Daily horizon | 7 days |
-| Coverage | All active monitoring sites |
-| Historical forecasts | Not available |
-| Pollutants | PM2.5 (PM10 in future versions) |
-
----
-
-## Upgrade to Premium
+## Caching and Update Frequency
-To unlock forecast access, log in to [analytics.airqo.net](https://analytics.airqo.net), go to **Account Settings → Subscription**, and select Premium Tier.
+Forecast results are cached per request parameters. Cache is invalidated automatically when the underlying forecast data changes — typically aligned with model run cycles. For most integrations, caching responses for **1–3 hours** gives a good balance between API call volume and data freshness. See [Best Practices →](../reference/best-practices.md) for caching guidance.
diff --git a/src/docs-website/docs/api/reference/error-codes.md b/src/docs-website/docs/api/reference/error-codes.md
index 00995665fd..5ba11bd49b 100644
--- a/src/docs-website/docs/api/reference/error-codes.md
+++ b/src/docs-website/docs/api/reference/error-codes.md
@@ -5,11 +5,15 @@ sidebar_label: Error Codes
# Error Codes
-All AirQo API endpoints return consistent error responses so you can handle failures programmatically.
+AirQo API endpoints use two different error response formats depending on the API family. Match your error handling to the endpoint you are calling.
---
-## Error response format
+## Error response formats
+
+### Devices and Analytics API
+
+Used by: measurement endpoints (`/api/v2/devices/...`), Analytics API (`/api/v3/public/analytics/...`), and metadata endpoints (`/api/v2/devices/metadata/...`).
```json
{
@@ -19,7 +23,15 @@ All AirQo API endpoints return consistent error responses so you can handle fail
}
```
-For forecast endpoints, the shape uses `success: false`:
+| Field | Type | Description |
+|-------|------|-------------|
+| `status` | string | `"error"` on failure |
+| `message` | string | Human-readable description |
+| `code` | string | Machine-readable error code (e.g. `"NOT_FOUND"`, `"UNAUTHORIZED"`) |
+
+### Forecast API
+
+Used by: `/api/v2/predict/daily-forecasting/` and `/api/v2/predict/hourly-forecasting/`.
```json
{
@@ -29,6 +41,12 @@ For forecast endpoints, the shape uses `success: false`:
}
```
+| Field | Type | Description |
+|-------|------|-------------|
+| `success` | boolean | Always `false` on error |
+| `message` | string | Human-readable description |
+| `error` | string | HTTP status phrase (e.g. `"Unauthorized"`, `"Forbidden"`) |
+
---
## HTTP status codes
@@ -57,6 +75,24 @@ For forecast endpoints, the shape uses `success: false`:
---
+### 400 Bad Request — conflicting forecast parameters
+
+```json
+{
+ "success": false,
+ "message": "Please specify only one of site_id, grid_id, or cohort_id.",
+ "data": {
+ "forecasts": []
+ }
+}
+```
+
+**Cause:** More than one of `site_id`, `grid_id`, or `cohort_id` was provided in a single forecast request.
+
+**Solution:** Provide exactly one identifier per request.
+
+---
+
### 401 Unauthorized
```json
diff --git a/src/docs-website/docs/api/reference/finding-ids.md b/src/docs-website/docs/api/reference/finding-ids.md
index 2abccfcb28..3d16f5417e 100644
--- a/src/docs-website/docs/api/reference/finding-ids.md
+++ b/src/docs-website/docs/api/reference/finding-ids.md
@@ -14,7 +14,7 @@ To use the AirQo API, you need the right identifier for your data source. This p
A Cohort ID groups your organisation's devices together. Cohorts are managed by AirQo.
**How to find your Cohort ID:**
-- **Public Cohorts:** Browse the metadata endpoints at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata)
+- **Public Cohorts:** Use the [Metadata API — List all cohorts](./metadata.md#list-all-public-cohorts) endpoint to browse all publicly visible cohorts
- **Private Cohorts:** Contact [support@airqo.net](mailto:support@airqo.net) — your Cohort ID will be shared securely once your organisation's Cohort is set up
A Cohort ID looks like: `64b9c7d5e3f82b0014c5a123`
@@ -27,8 +27,9 @@ A Grid ID represents a geographical area (typically a city or district). Grids a
**How to find your Grid ID:**
-1. Visit the [metadata documentation](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata)
-2. Use the `admin_level` query parameter to filter by country, province, or city
+1. Call the [grid summary endpoint](./metadata.md#grid-summary-with-site-details): `GET /api/v2/devices/grids/summary`
+2. Use the `admin_level=city` query parameter to filter to city-level grids
+3. Locate your city by `long_name` and copy its `_id` value
**Known Grid IDs (examples):**
@@ -36,7 +37,7 @@ A Grid ID represents a geographical area (typically a city or district). Grids a
|------|---------|
| Nairobi | `64b7ac8fd7249f0029feca80` |
-For other cities, use the metadata endpoint to browse all available Grids.
+For other cities, use the [Metadata API](./metadata.md) to browse all available grids.
---
@@ -45,8 +46,9 @@ For other cities, use the metadata endpoint to browse all available Grids.
A Site ID identifies a specific physical monitoring location.
**How to find Site IDs:**
-- Call the [recent measurements endpoint](../for-cities/recent-measurements.md) for your Grid and collect the `site_id` field from each measurement
-- Browse the metadata endpoints for a full list: [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata)
+- Call the [grid generate endpoint](./metadata.md#get-all-site-and-device-ids-for-a-grid) (`GET /api/v2/devices/grids/{GRID_ID}/generate`) to list every site (and its devices) within a grid
+- Call the [Metadata API — List all sites](./metadata.md#list-all-public-sites) to browse all public monitoring sites
+- Or call the [recent measurements endpoint](../for-cities/recent-measurements.md) for your Grid and collect the `site_id` field from each measurement record
A Site ID looks like: `64f7b3e8c9d25a0013f2d456`
@@ -75,6 +77,23 @@ A Device ID uniquely identifies a sensor unit. Device names follow the pattern `
---
+## Browsing via the Metadata API
+
+The [Metadata API](./metadata.md) provides dedicated endpoints for discovering every public resource:
+
+| What you want | Endpoint |
+|---------------|----------|
+| Browse all grids | `GET /api/v2/devices/metadata/grids` |
+| Browse grids by city/country | `GET /api/v2/devices/grids/summary?admin_level=city` |
+| Look up one grid | `GET /api/v2/devices/metadata/grids/{GRID_ID}` |
+| Sites in a grid | `GET /api/v2/devices/grids/{GRID_ID}/generate` |
+| Browse all cohorts | `GET /api/v2/devices/metadata/cohorts` |
+| Sites and devices in a cohort | `GET /api/v2/devices/cohorts/{COHORT_ID}/generate` |
+| Browse all sites | `GET /api/v2/devices/metadata/sites` |
+| Browse all devices | `GET /api/v2/devices/metadata/devices` |
+
+---
+
## Still can't find your ID?
Contact [support@airqo.net](mailto:support@airqo.net) with your organisation's name or the city/region you are monitoring. The team can look up the correct IDs for you.
diff --git a/src/docs-website/docs/api/reference/metadata.md b/src/docs-website/docs/api/reference/metadata.md
new file mode 100644
index 0000000000..c1da52bb77
--- /dev/null
+++ b/src/docs-website/docs/api/reference/metadata.md
@@ -0,0 +1,448 @@
+---
+sidebar_position: 6
+sidebar_label: Metadata API
+---
+
+# Metadata API
+
+The Metadata API lets you browse the AirQo monitoring infrastructure — grids, cohorts, sites, devices, and geographic boundaries — so you can discover the right identifiers before querying measurement or forecast data.
+
+All endpoints require your `token` query parameter. See [Authentication →](../getting-started/authentication.md).
+
+---
+
+## Grids
+
+Grids are geographically-defined groupings of monitoring sites, typically aligned to a city or administrative district.
+
+### List all grids
+
+```http
+GET /api/v2/devices/metadata/grids?token={SECRET_TOKEN}
+```
+
+Returns all publicly visible grids.
+
+**Query parameters**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `token` | string | — | Required |
+| `limit` | integer | 1000 | Max results per page (max: 2000) |
+| `skip` | integer | 0 | Records to skip |
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "grids": [
+ {
+ "_id": "64b7ac8fd7249f0029feca80",
+ "name": "nairobi_city",
+ "long_name": "Nairobi City",
+ "admin_level": "city",
+ "visibility": true,
+ "network": "airqo",
+ "createdAt": "2023-07-19T10:25:00.000Z"
+ }
+ ],
+ "meta": {
+ "total": 42,
+ "skip": 0,
+ "limit": 1000,
+ "page": 1,
+ "pages": 1
+ }
+}
+```
+
+---
+
+### Grid summary with site details
+
+```http
+GET /api/v2/devices/grids/summary?token={SECRET_TOKEN}
+```
+
+Returns grids with their constituent site details nested inside. This is the most useful endpoint for **discovering Grid IDs and the sites within them**.
+
+**Query parameters**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `token` | string | Required |
+| `admin_level` | string | Filter by level: `country`, `province`, `city`, `district`, `subcounty`, `county`, `division`, `parish` |
+| `id` | string | Filter by a specific Grid ID |
+
+**Example — filter to city-level grids**
+
+```bash
+curl "https://api.airqo.net/api/v2/devices/grids/summary?admin_level=city&token=YOUR_SECRET_TOKEN"
+```
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "grids": [
+ {
+ "_id": "64b7ac8fd7249f0029feca80",
+ "name": "nairobi_city",
+ "long_name": "Nairobi City",
+ "admin_level": "city",
+ "visibility": true,
+ "network": "airqo",
+ "numberOfSites": 12,
+ "createdAt": "2023-07-19T10:25:00.000Z",
+ "sites": [
+ {
+ "_id": "64f7b3e8c9d25a0013f2d456",
+ "name": "nairobi_road",
+ "long_name": "Nairobi Road, Nairobi",
+ "latitude": -1.2921,
+ "longitude": 36.8219,
+ "data_provider": "airqo"
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+### Get a single grid
+
+```http
+GET /api/v2/devices/metadata/grids/{GRID_ID}?token={SECRET_TOKEN}
+```
+
+Returns full details for one grid.
+
+---
+
+### Get all site and device IDs for a grid
+
+```http
+GET /api/v2/devices/grids/{GRID_ID}/generate?token={SECRET_TOKEN}
+```
+
+Returns every site and device ID belonging to the grid. Use this to build a list of `site_id` or `device_id` values for measurement and forecast queries.
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "sites": [
+ {
+ "site_id": "64f7b3e8c9d25a0013f2d456",
+ "site_name": "Nairobi Road",
+ "devices": [
+ {
+ "device_id": "65c8d4a2f1b45c0012a3e789",
+ "device_name": "airqo_bx2847"
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+### List countries
+
+```http
+GET /api/v2/devices/grids/countries?token={SECRET_TOKEN}
+```
+
+Returns all countries that have AirQo monitoring coverage. Useful for filtering the grid summary by country before drilling down to a city.
+
+---
+
+## Cohorts
+
+Cohorts are organisationally-defined groups of devices, managed by the AirQo team on behalf of partner organisations.
+
+### List all public cohorts
+
+```http
+GET /api/v2/devices/metadata/cohorts?token={SECRET_TOKEN}
+```
+
+Returns all publicly visible cohorts.
+
+**Query parameters**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `token` | string | — | Required |
+| `limit` | integer | 1000 | Max results per page (max: 2000) |
+| `skip` | integer | 0 | Records to skip |
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "cohorts": [
+ {
+ "_id": "64b9c7d5e3f82b0014c5a123",
+ "name": "partner_network",
+ "long_name": "Partner Monitoring Network",
+ "visibility": true,
+ "network": "airqo",
+ "createdAt": "2023-08-01T00:00:00.000Z"
+ }
+ ],
+ "meta": {
+ "total": 5,
+ "skip": 0,
+ "limit": 1000,
+ "page": 1,
+ "pages": 1
+ }
+}
+```
+
+:::note Private Cohorts
+Private cohorts do not appear in this list. If your organisation has a private Cohort, contact [support@airqo.net](mailto:support@airqo.net) to obtain your Cohort ID.
+:::
+
+---
+
+### Get a single cohort
+
+```http
+GET /api/v2/devices/metadata/cohorts/{COHORT_ID}?token={SECRET_TOKEN}
+```
+
+Returns full details for one cohort.
+
+---
+
+### Get all site and device IDs for a cohort
+
+```http
+GET /api/v2/devices/cohorts/{COHORT_ID}/generate?token={SECRET_TOKEN}
+```
+
+Returns every site and device ID belonging to the cohort. The structure matches the grid generate endpoint.
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "sites": [
+ {
+ "site_id": "64f7b3e8c9d25a0013f2d456",
+ "site_name": "Kampala Central",
+ "devices": [
+ {
+ "device_id": "65c8d4a2f1b45c0012a3e789",
+ "device_name": "airqo_bx2847"
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+## Sites
+
+Sites are fixed physical locations where sensors are deployed.
+
+### List all public sites
+
+```http
+GET /api/v2/devices/metadata/sites?token={SECRET_TOKEN}
+```
+
+Returns all publicly visible monitoring sites.
+
+**Query parameters**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `token` | string | — | Required |
+| `limit` | integer | 1000 | Max results per page (max: 2000) |
+| `skip` | integer | 0 | Records to skip |
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "sites": [
+ {
+ "_id": "64f7b3e8c9d25a0013f2d456",
+ "name": "kampala_road",
+ "long_name": "Kampala Road, Kampala",
+ "generated_name": "Kampala Road",
+ "latitude": 0.3476,
+ "longitude": 32.5825,
+ "network": "airqo",
+ "visibility": true,
+ "online_status": "online"
+ }
+ ],
+ "meta": {
+ "total": 250,
+ "skip": 0,
+ "limit": 1000,
+ "page": 1,
+ "pages": 1
+ }
+}
+```
+
+---
+
+### Get a single site
+
+```http
+GET /api/v2/devices/metadata/sites/{SITE_ID}?token={SECRET_TOKEN}
+```
+
+Returns full details for one monitoring site, including coordinates, administrative context, and current online status.
+
+---
+
+## Devices
+
+Devices are individual sensor units. Each device is deployed at a site.
+
+### List all public devices
+
+```http
+GET /api/v2/devices/metadata/devices?token={SECRET_TOKEN}
+```
+
+Returns all publicly visible devices.
+
+**Query parameters**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `token` | string | — | Required |
+| `limit` | integer | 1000 | Max results per page (max: 2000) |
+| `skip` | integer | 0 | Records to skip |
+
+**Example response**
+
+```json
+{
+ "success": true,
+ "devices": [
+ {
+ "_id": "65c8d4a2f1b45c0012a3e789",
+ "name": "airqo_bx2847",
+ "category": "lowcost",
+ "status": "deployed",
+ "site_id": "64f7b3e8c9d25a0013f2d456",
+ "network": "airqo",
+ "isOnline": true,
+ "lastActive": "2025-09-28T08:45:00.000Z"
+ }
+ ],
+ "meta": {
+ "total": 850,
+ "skip": 0,
+ "limit": 1000,
+ "page": 1,
+ "pages": 1
+ }
+}
+```
+
+**Device categories**
+
+| Category | Description |
+|----------|-------------|
+| `lowcost` | Low-cost optical particle sensors (the majority of AirQo devices) |
+| `bam` | Beta Attenuation Monitor — reference-grade instruments |
+| `gas` | Gas sensor units |
+
+---
+
+### Get a single device
+
+```http
+GET /api/v2/devices/metadata/devices/{DEVICE_ID}?token={SECRET_TOKEN}
+```
+
+Returns full details for one device, including its deployment site and online status.
+
+---
+
+## Locations
+
+The locations endpoint lets you browse administrative boundaries at different geographic levels — useful for understanding what areas AirQo covers before querying a grid.
+
+### List locations
+
+```http
+GET /api/v2/devices/locations?token={SECRET_TOKEN}
+```
+
+**Query parameters**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `token` | string | Required |
+| `admin_level` | string | Filter by level: `country`, `province`, `state`, `district`, `county`, `subcounty`, `division`, `parish`, `village` |
+| `name` | string | Filter by location name (partial match) |
+| `limit` | integer | Max results per page |
+| `skip` | integer | Records to skip |
+
+**Example — list all countries**
+
+```bash
+curl "https://api.airqo.net/api/v2/devices/locations?admin_level=country&token=YOUR_SECRET_TOKEN"
+```
+
+---
+
+## Pagination
+
+All list endpoints support `limit` and `skip` parameters for pagination. The `meta` object in every response includes `total`, `page`, and `pages` to help you navigate large result sets.
+
+**Example — page through all sites in blocks of 100**
+
+```bash
+# Page 1
+curl "https://api.airqo.net/api/v2/devices/metadata/sites?limit=100&skip=0&token=YOUR_SECRET_TOKEN"
+
+# Page 2
+curl "https://api.airqo.net/api/v2/devices/metadata/sites?limit=100&skip=100&token=YOUR_SECRET_TOKEN"
+```
+
+---
+
+## Common workflows
+
+### Find a Grid ID for a city
+
+1. Call the grid summary endpoint with `admin_level=city`
+2. Locate your city by `long_name`
+3. Copy the `_id` value — that is your Grid ID
+
+### Find all Site IDs in your Grid
+
+1. Call `GET /api/v2/devices/grids/{GRID_ID}/generate`
+2. Collect the `site_id` from each entry in the `sites` array
+
+### Find all Device IDs in your Cohort
+
+1. Call `GET /api/v2/devices/cohorts/{COHORT_ID}/generate`
+2. For each site, collect `device_id` values from the nested `devices` array
+
+---
+
+See also: [Finding IDs →](./finding-ids.md)
diff --git a/src/docs-website/docs/api/reference/response-structure.md b/src/docs-website/docs/api/reference/response-structure.md
index 51178e310f..2b45ddda8a 100644
--- a/src/docs-website/docs/api/reference/response-structure.md
+++ b/src/docs-website/docs/api/reference/response-structure.md
@@ -190,28 +190,36 @@ Used by: `/api/v2/spatial/heatmaps` endpoints.
## Forecast response
-Used by: `/api/v2/predict/hourly-forecast` and `/api/v2/predict/daily-forecast`.
+Used by: `/api/v2/predict/daily-forecasting/` and `/api/v2/predict/hourly-forecasting/`.
+
+See [Forecast API →](../forecasts/overview.md) for the full response schema, field reference, and scope-based variants.
+
+**Daily forecasting — abbreviated example:**
```json
{
"success": true,
- "message": "Forecasts retrieved successfully",
- "forecasts": [
- {
- "time": "2025-09-29T14:00:00+00:00",
- "pm2_5": 24.5,
- "health_tips": ["Air quality is acceptable for most people"]
- }
- ],
- "forecast_metadata": {
- "model_version": "v2.3",
- "generated_at": "2025-09-29T12:00:00+00:00",
- "location": {
- "site_id": "64f7b3e8c9d25a0013f2d456",
- "latitude": 0.3476,
- "longitude": 32.5825
- },
- "forecast_horizon_hours": 168
+ "data": {
+ "start_date": "2026-06-03",
+ "end_date": "2026-06-09",
+ "days": 7,
+ "total": 1,
+ "units": { "pm2_5": "ug/m3", "forecast_confidence": "%" },
+ "forecasts": [
+ {
+ "site_details": { "site_id": "64f7b3e8c9d25a0013f2d456", "site_name": "Kampala Central" },
+ "forecasts": [
+ {
+ "date": "2026-06-03",
+ "forecast": { "pm2_5_mean": 28.4, "forecast_confidence": 84.5 },
+ "aqi": { "category": "Moderate", "label": "Air quality is acceptable for most people." },
+ "met": { "air_temperature": 24.7, "relative_humidity": 72.1 }
+ }
+ ]
+ }
+ ]
}
}
```
+
+**Hourly forecasting** uses the same envelope but with `start_timestamp`, `end_timestamp`, `hours`, and pagination fields (`page`, `pages`). Individual forecast items use `timestamp` instead of `date` and include `pm2_5_q10` / `pm2_5_q90` uncertainty bounds instead of `pm2_5_low` / `pm2_5_high`.
diff --git a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart
index 822626efe7..32e16b9bfb 100644
--- a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart
+++ b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart
@@ -263,18 +263,21 @@ class _DashboardPageState extends State with UiLoggy {
}
if (state is DashboardLoadingError && !state.hasCache) {
+ final isOffline = state.isOffline;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
- Icons.cloud_off,
+ isOffline ? Icons.cloud_off : Icons.error_outline,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
TranslatedText(
- "Couldn't connect to the internet",
+ isOffline
+ ? "Couldn't connect to the internet"
+ : "Couldn't load air quality data",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -283,7 +286,9 @@ class _DashboardPageState extends State with UiLoggy {
),
SizedBox(height: 8),
TranslatedText(
- "Please check your connection and try again",
+ isOffline
+ ? "Please check your connection and try again"
+ : "Something went wrong. Please try again later",
style: TextStyle(
fontSize: 16,
color: Theme.of(context).textTheme.bodyMedium?.color,
diff --git a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart
index beb52611b4..68a13c470f 100644
--- a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart
+++ b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart
@@ -1,5 +1,6 @@
import 'package:airqo/src/app/learn/models/lesson_response_model.dart';
import 'package:airqo/src/app/learn/repository/kya_repository.dart';
+import 'package:airqo/src/app/shared/services/cache_manager.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:loggy/loggy.dart';
@@ -9,6 +10,8 @@ part 'kya_state.dart';
class KyaBloc extends Bloc with UiLoggy {
final KyaRepository repository;
+ final CacheManager _cacheManager = CacheManager();
+
KyaBloc(this.repository) : super(KyaInitial()) {
on(_onLoadLessons);
on(_onRefreshLessons);
@@ -32,10 +35,14 @@ class KyaBloc extends Bloc with UiLoggy {
emit(LessonsLoadingError(
message: e.toString(),
cachedModel: cachedModel,
+ isOffline: !_cacheManager.isConnected,
));
} catch (cacheError) {
loggy.error('Error fetching cached lessons: $cacheError');
- emit(LessonsLoadingError(message: e.toString()));
+ emit(LessonsLoadingError(
+ message: e.toString(),
+ isOffline: !_cacheManager.isConnected,
+ ));
}
}
}
@@ -62,6 +69,7 @@ class KyaBloc extends Bloc with UiLoggy {
emit(LessonsLoadingError(
message: e.toString(),
cachedModel: cachedModel,
+ isOffline: !_cacheManager.isConnected,
));
}
}
diff --git a/src/mobile/lib/src/app/learn/bloc/kya_state.dart b/src/mobile/lib/src/app/learn/bloc/kya_state.dart
index de5c8a01ca..71bb64c556 100644
--- a/src/mobile/lib/src/app/learn/bloc/kya_state.dart
+++ b/src/mobile/lib/src/app/learn/bloc/kya_state.dart
@@ -33,12 +33,14 @@ class LessonsLoaded extends KyaState {
class LessonsLoadingError extends KyaState {
final String message;
final LessonResponseModel? cachedModel;
-
+ final bool isOffline;
+
const LessonsLoadingError({
required this.message,
this.cachedModel,
+ this.isOffline = false,
});
-
+
@override
- List get props => [message, cachedModel];
+ List get props => [message, cachedModel, isOffline];
}
\ No newline at end of file
diff --git a/src/mobile/lib/src/app/learn/pages/kya_page.dart b/src/mobile/lib/src/app/learn/pages/kya_page.dart
index 5cc2edc755..e5834122e4 100644
--- a/src/mobile/lib/src/app/learn/pages/kya_page.dart
+++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart
@@ -198,7 +198,11 @@ class _KyaPageState extends State with UiLoggy {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- const Icon(Icons.cloud_off, size: 64, color: Colors.grey),
+ Icon(
+ state.isOffline ? Icons.cloud_off : Icons.error_outline,
+ size: 64,
+ color: Colors.grey,
+ ),
const SizedBox(height: 16),
TranslatedText(
"Unable to load content",
@@ -212,7 +216,9 @@ class _KyaPageState extends State with UiLoggy {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: TranslatedText(
- "Please check your connection and try again",
+ state.isOffline
+ ? "Please check your connection and try again"
+ : "Something went wrong. Please try again later",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
diff --git a/src/mobile/lib/src/app/map/pages/map_page.dart b/src/mobile/lib/src/app/map/pages/map_page.dart
index 1d129e5f02..a15d97c78d 100644
--- a/src/mobile/lib/src/app/map/pages/map_page.dart
+++ b/src/mobile/lib/src/app/map/pages/map_page.dart
@@ -13,6 +13,7 @@ import 'package:airqo/src/app/map/widgets/map_overlay_controls.dart';
import 'package:airqo/src/app/map/widgets/map_search_sheet.dart';
import 'package:airqo/src/app/map/widgets/map_style_picker.dart';
import 'package:airqo/src/app/other/places/bloc/google_places_bloc.dart';
+import 'package:airqo/src/app/shared/services/cache_manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -348,6 +349,8 @@ class _MapScreenState extends State
(markers.isEmpty || allMeasurements.isEmpty)) {
_initializeWithData(response);
}
+ } else if (state is MapLoadingError) {
+ if (mounted) setState(() => isInitializing = false);
}
},
),
@@ -383,7 +386,7 @@ class _MapScreenState extends State
markers.isEmpty &&
allMeasurements.isEmpty &&
!isRetrying)
- ? MapErrorView(onRetry: _retryLoading)
+ ? MapErrorView(onRetry: _retryLoading, isOffline: !CacheManager().isConnected)
: _buildMapView(),
),
);
diff --git a/src/mobile/lib/src/app/map/widgets/map_error_view.dart b/src/mobile/lib/src/app/map/widgets/map_error_view.dart
index 4594449190..dffbbdd1f9 100644
--- a/src/mobile/lib/src/app/map/widgets/map_error_view.dart
+++ b/src/mobile/lib/src/app/map/widgets/map_error_view.dart
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
class MapErrorView extends StatelessWidget {
final VoidCallback onRetry;
+ final bool isOffline;
- const MapErrorView({super.key, required this.onRetry});
+ const MapErrorView({super.key, required this.onRetry, this.isOffline = false});
@override
Widget build(BuildContext context) {
@@ -13,7 +14,7 @@ class MapErrorView extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- const Icon(Icons.map_outlined, size: 64, color: Colors.grey),
+ Icon(isOffline ? Icons.cloud_off : Icons.map_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 16),
TranslatedText(
"Unable to load map data",
@@ -24,7 +25,9 @@ class MapErrorView extends StatelessWidget {
),
const SizedBox(height: 8),
TranslatedText(
- "Please check your connection and try again",
+ isOffline
+ ? "Please check your connection and try again"
+ : "Something went wrong. Please try again later",
style: TextStyle(
fontSize: 16,
color: Theme.of(context).textTheme.bodyMedium?.color),
diff --git a/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart b/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart
index 702cb53472..f28cabf752 100644
--- a/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart
+++ b/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart
@@ -70,7 +70,7 @@ class _SelectLanguagePageState extends State {
if (needsMlKitDownload) {
await MlKitTranslationService()
.prepareModel(language.code)
- .timeout(const Duration(seconds: 30));
+ .timeout(const Duration(minutes: 3));
await MlKitTranslationService()
.prepareCriticalStrings(language.code)
.timeout(const Duration(seconds: 30));
@@ -88,10 +88,12 @@ class _SelectLanguagePageState extends State {
debugPrint('Language preparation failed: $e\n$stackTrace');
if (!mounted) return;
setState(() => _preparingCode = null);
- final isNetworkError = e is SocketException || e is TimeoutException;
+ final isNetworkError = e is SocketException;
final message = isNetworkError
? 'No internet connection. Please check your network and try again.'
- : 'Failed to prepare language. Please try again.';
+ : e is TimeoutException
+ ? 'Download is taking too long. Please check your connection and try again.'
+ : 'Failed to prepare language. Please try again.';
messenger.showSnackBar(
SnackBar(
content: Text(message),
diff --git a/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md b/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md
index 0788040c51..8af9d43757 100644
--- a/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md
+++ b/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md
@@ -37,17 +37,20 @@ Added new utility methods to handle timezone-aware date formatting:
**File**: `src/modules/analytics/components/DownloadDialog.tsx`
**Memory Leak Prevention:**
+
- Added `useCallback` for `handleConfirmDownload` with proper dependencies
- Added `useCallback` for `handleDataTypeChange` to prevent unnecessary re-renders
- Memoized `frequencyOptions` and `pollutantOptions` to prevent recalculation
- Memoized `siteDisplayData` to optimize site list rendering
**Improved Error Handling:**
+
- Removed date formatting logic since dates come pre-formatted from FilterBar
- Enhanced error messages with proper toast notifications
- Added try-catch with specific error handling using `getUserFriendlyErrorMessage`
**Code Optimization:**
+
- Removed duplicate code and unnecessary date manipulations
- Simplified date handling by using pre-formatted dates from filters
- Improved component performance with memoization
@@ -55,12 +58,14 @@ Added new utility methods to handle timezone-aware date formatting:
### 5. Fixed Backend Integration Issues
**DateTime Format Issue Fixed:**
+
- Backend now receives timezone-aware dates in format: `"2025-07-20T00:00:00.000Z"`
- Start dates set to 00:00:00.000 (beginning of day)
- End dates set to 23:59:59.999 (end of day)
- All dates include proper timezone information (`Z` suffix for UTC)
**Request Payload:**
+
- Added required `device_category: 'lowcost'` field
- Ensured all required fields are properly formatted
- Dates are passed directly without additional processing
@@ -90,9 +95,9 @@ The download request now sends dates in the exact format expected by the backend
{
"startDateTime": "2025-07-20T00:00:00.000Z",
"endDateTime": "2025-07-28T23:59:59.999Z",
- "device_category": "lowcost",
+ "device_category": "lowcost"
// ... other required fields
}
```
-This should resolve the "startDateTime must be timezone-aware" error from the backend.
\ No newline at end of file
+This should resolve the "startDateTime must be timezone-aware" error from the backend.
diff --git a/src/platform/docs/SWR_INTEGRATION_README.md b/src/platform/docs/SWR_INTEGRATION_README.md
index d78f96f5fb..ba9dd84d23 100644
--- a/src/platform/docs/SWR_INTEGRATION_README.md
+++ b/src/platform/docs/SWR_INTEGRATION_README.md
@@ -15,7 +15,12 @@ SWR provides powerful caching, revalidation, and synchronization capabilities fo
### Authentication Mutations
```typescript
-import { useLogin, useRegister, useForgotPassword, useResetPassword } from '@/shared/hooks/useAuth';
+import {
+ useLogin,
+ useRegister,
+ useForgotPassword,
+ useResetPassword,
+} from '@/shared/hooks/useAuth';
// Login
const { trigger: login, isMutating: isLoggingIn } = useLogin();
@@ -28,7 +33,7 @@ await register({
lastName: 'Doe',
email: 'john@example.com',
password: 'password',
- category: 'individual'
+ category: 'individual',
});
// Forgot Password
@@ -171,4 +176,4 @@ The SWR hooks work seamlessly with NextAuth:
## Example Component
-See `src/shared/components/AuthExample.tsx` for a complete working example of all hooks in action.
\ No newline at end of file
+See `src/shared/components/AuthExample.tsx` for a complete working example of all hooks in action.
diff --git a/src/platform/src/app/(dashboard)/request-organization/page.tsx b/src/platform/src/app/(dashboard)/request-organization/page.tsx
index 375ab162c4..96d2a575b0 100644
--- a/src/platform/src/app/(dashboard)/request-organization/page.tsx
+++ b/src/platform/src/app/(dashboard)/request-organization/page.tsx
@@ -61,7 +61,9 @@ const INITIAL_FORM: FormData = {
const RequestOrganizationPage = () => {
const [formData, setFormData] = useState(INITIAL_FORM);
- const [validationErrors, setValidationErrors] = useState({});
+ const [validationErrors, setValidationErrors] = useState(
+ {}
+ );
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const { trigger: createRequest, isMutating: isCreating } =
diff --git a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx
index d9158bed44..948668674e 100644
--- a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx
@@ -6,8 +6,10 @@ import { Button, Card, PageHeading } from '@/shared/components/ui';
import { Tooltip } from 'flowbite-react';
import { LoadingState } from '@/shared/components/ui/loading-state';
import { toast } from '@/shared/components/ui';
+import { WarningBanner } from '@/shared/components/ui/banner';
import { formatDate, parseDate } from '@/shared/utils';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
import {
AqArrowLeft,
AqEdit05,
@@ -22,6 +24,7 @@ import useSWR from 'swr';
import Dialog from '@/shared/components/ui/dialog';
import EditClientDialog from '@/modules/api-client/components/EditClientDialog';
import InactiveClientDialog from '@/modules/api-client/components/InactiveClientDialog';
+import TokenSecurityDialog from '@/modules/api-client/components/TokenSecurityDialog';
import { PermissionGuard } from '@/shared/components/PermissionGuard';
import { useRBAC, useUser } from '@/shared/hooks';
@@ -40,10 +43,12 @@ const ClientDetailsPage: React.FC = () => {
activate: true,
});
const [generateTokenDialogOpen, setGenerateTokenDialogOpen] = useState(false);
+ const [tokenSecurityDialogOpen, setTokenSecurityDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isRefreshingSecret, setIsRefreshingSecret] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isGeneratingToken, setIsGeneratingToken] = useState(false);
+ const [isReinstatingToken, setIsReinstatingToken] = useState(false);
const [inactiveDialogState, setInactiveDialogState] = useState<{
isOpen: boolean;
clientId: string;
@@ -90,6 +95,14 @@ const ClientDetailsPage: React.FC = () => {
: '—';
const requiresClientSecret = Boolean(client?.requireClientSecret);
+ const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const describeAllowedDays = (days: number[] | undefined) => {
+ if (!days || days.length === 0) {
+ return 'All days';
+ }
+
+ return days.map(day => dayLabels[day] || String(day)).join(', ');
+ };
const handleBack = () => {
router.push('/system/clients');
@@ -181,6 +194,34 @@ const ClientDetailsPage: React.FC = () => {
}
};
+ const handleOpenTokenSecurity = () => {
+ if (!token) {
+ toast.error('Token data is unavailable for this client');
+ return;
+ }
+
+ setTokenSecurityDialogOpen(true);
+ };
+
+ const handleReinstateToken = async () => {
+ if (!token?.token) {
+ toast.error('Token data is unavailable for this client');
+ return;
+ }
+
+ setIsReinstatingToken(true);
+ try {
+ await clientService.reinstateToken(token.token);
+ toast.success('Token reinstated successfully');
+ mutate();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Reinstate token error:', sanitizeErrorForLogging(error));
+ } finally {
+ setIsReinstatingToken(false);
+ }
+ };
+
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
@@ -571,6 +612,182 @@ const ClientDetailsPage: React.FC = () => {
+ {token && (
+
+
+
+
Token Security
+
+ Review grid, cohort, origin, and schedule restrictions for
+ this token.
+
+
+
+ Manage Security
+
+
+
+
+ {token.request_pattern?.auto_suspended && (
+
+
+ This token was suspended because suspicious activity was
+ detected.
+
+ {token.request_pattern.suspension_reason && (
+
+ Reason: {' '}
+ {token.request_pattern.suspension_reason}
+
+ )}
+ {token.request_pattern.suspended_at && (
+
+ Suspended at: {' '}
+ {formatDate(token.request_pattern.suspended_at, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ )}
+
+ }
+ actions={
+
+ Reinstate token
+
+ }
+ />
+ )}
+
+
+
+
+ Grid restrictions
+
+
+ {token.allowed_grids && token.allowed_grids.length > 0 ? (
+ token.allowed_grids.map(grid => (
+
+ {grid}
+
+ ))
+ ) : (
+
+ Unrestricted
+
+ )}
+
+
+
+
+
+ Cohort restrictions
+
+
+ {token.allowed_cohorts &&
+ token.allowed_cohorts.length > 0 ? (
+ token.allowed_cohorts.map(cohort => (
+
+ {cohort}
+
+ ))
+ ) : (
+
+ Unrestricted
+
+ )}
+
+
+
+
+
+ Allowed origins
+
+
+ {token.allowed_origins &&
+ token.allowed_origins.length > 0 ? (
+ token.allowed_origins.map(origin => (
+
+ {origin}
+
+ ))
+ ) : (
+
+ Unrestricted
+
+ )}
+
+
+
+
+
+ Access schedule
+
+
+ {token.access_schedule?.enabled ? (
+ <>
+
+ Days:{' '}
+ {describeAllowedDays(
+ token.access_schedule.allowed_days
+ )}
+
+ {token.access_schedule.allowed_hours_utc &&
+ typeof token.access_schedule.allowed_hours_utc.start ===
+ 'number' &&
+ typeof token.access_schedule.allowed_hours_utc.end ===
+ 'number' ? (
+
+ Hours UTC:{' '}
+ {String(
+ token.access_schedule.allowed_hours_utc.start
+ ).padStart(2, '0')}
+ :00 to{' '}
+ {String(
+ token.access_schedule.allowed_hours_utc.end
+ ).padStart(2, '0')}
+ :00
+
+ ) : (
+
Hours UTC: All day
+ )}
+ >
+ ) : (
+
Disabled
+ )}
+
+
+
+
+
+ )}
+
{/* Edit Dialog */}
{
}}
/>
+ {client.access_token && (
+ setTokenSecurityDialogOpen(false)}
+ token={client.access_token}
+ clientName={client.name}
+ onSuccess={() => {
+ setTokenSecurityDialogOpen(false);
+ mutate();
+ }}
+ />
+ )}
+
{/* Inactive Client Dialog */}
{
};
const handleRefresh = useCallback(async () => {
- await mutate();
+ try {
+ await refreshWithToast(() => mutate(), 'Clients refreshed successfully');
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Refresh clients error:', error);
+ }
}, [mutate]);
const renderStatus = useCallback((value: unknown, item: TableClient) => {
diff --git a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx
new file mode 100644
index 0000000000..b4414590d5
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx
@@ -0,0 +1,789 @@
+'use client';
+
+import React, { useCallback, useMemo, useState } from 'react';
+import useSWR from 'swr';
+import { Tooltip } from 'flowbite-react';
+import {
+ AqCopy06,
+ AqEdit05,
+ AqMail04,
+ AqPlus,
+ AqRefreshCw05,
+ AqTrash01,
+} from '@airqo/icons-react';
+import { PermissionGuard } from '@/shared/components';
+import {
+ Button,
+ Card,
+ EmptyState,
+ Input,
+ LoadingState,
+ PageHeading,
+ Select,
+ TextInput,
+ toast,
+} from '@/shared/components/ui';
+import Dialog from '@/shared/components/ui/dialog';
+import { ServerSideTable } from '@/shared/components/ui/server-side-table';
+import { useUser } from '@/shared/hooks';
+import { applicationEmailConfigService } from '@/shared/services';
+import type { ApplicationEmailConfiguration } from '@/shared/types/api';
+import { formatDate } from '@/shared/utils';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
+
+type EmailConfigMode = 'replace' | 'add' | 'remove';
+
+type EmailConfigFormState = {
+ adminCCEmails: string;
+ applicationEmails: string;
+ mode: EmailConfigMode;
+};
+
+type EmailConfigRow = ApplicationEmailConfiguration & {
+ id: string;
+ [key: string]: unknown;
+};
+
+const EMAIL_SPLIT_REGEX = /[,\n;]+/;
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
+
+const DEFAULT_FORM_STATE: EmailConfigFormState = {
+ adminCCEmails: '',
+ applicationEmails: '',
+ mode: 'replace',
+};
+
+const parseEmailList = (value: string): string[] => {
+ const seen = new Set();
+
+ return value
+ .split(EMAIL_SPLIT_REGEX)
+ .map(entry => entry.trim().toLowerCase())
+ .filter(Boolean)
+ .filter(entry => {
+ if (seen.has(entry)) {
+ return false;
+ }
+
+ seen.add(entry);
+ return true;
+ });
+};
+
+const findInvalidEmails = (emails: string[]): string[] => {
+ return emails.filter(email => !EMAIL_REGEX.test(email));
+};
+
+const formatEmailInput = (emails: string[]): string => {
+ return emails.join(', ');
+};
+
+const formatEmailTextarea = (emails: string[]): string => {
+ return emails.join('\n');
+};
+
+const shortenId = (value: string): string => {
+ if (value.length <= 12) {
+ return value;
+ }
+
+ return `${value.slice(0, 6)}…${value.slice(-4)}`;
+};
+
+const renderEmailChips = (emails: string[], emptyLabel: string) => {
+ if (!emails.length) {
+ return {emptyLabel} ;
+ }
+
+ const visibleEmails = emails.slice(0, 3);
+ const remainingCount = emails.length - visibleEmails.length;
+
+ return (
+
+ {visibleEmails.map(email => (
+
+ {email}
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount} more
+
+ )}
+
+ );
+};
+
+const EmailConfigContent: React.FC = () => {
+ const { user } = useUser();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [dialogState, setDialogState] = useState<{
+ mode: 'create' | 'edit';
+ config: ApplicationEmailConfiguration | null;
+ } | null>(null);
+ const [deleteConfig, setDeleteConfig] =
+ useState(null);
+ const [formState, setFormState] =
+ useState(DEFAULT_FORM_STATE);
+
+ const {
+ data: response,
+ error,
+ isLoading,
+ mutate,
+ } = useSWR(
+ '/users/application-email-configs',
+ () => applicationEmailConfigService.getApplicationEmailConfigurations(),
+ {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+
+ const configs = useMemo(
+ () => response?.applicationEmailConfigurations || [],
+ [response]
+ );
+
+ const adminCCRecipientCount = useMemo(() => {
+ return configs.reduce(
+ (count, config) => count + parseEmailList(config.adminCCEmails).length,
+ 0
+ );
+ }, [configs]);
+
+ const applicationEmailCount = useMemo(() => {
+ return configs.reduce(
+ (count, config) => count + (config.applicationEmails?.length || 0),
+ 0
+ );
+ }, [configs]);
+
+ const configsWithApplications = useMemo(() => {
+ return configs.filter(config => (config.applicationEmails || []).length > 0)
+ .length;
+ }, [configs]);
+
+ const tableData = useMemo(
+ () =>
+ configs.map(config => ({
+ ...config,
+ id: config._id,
+ })),
+ [configs]
+ );
+
+ const openCreateDialog = useCallback(() => {
+ setFormState(DEFAULT_FORM_STATE);
+ setDialogState({
+ mode: 'create',
+ config: null,
+ });
+ }, []);
+
+ const openEditDialog = useCallback(
+ (config: ApplicationEmailConfiguration) => {
+ setFormState({
+ adminCCEmails: config.adminCCEmails || '',
+ applicationEmails: formatEmailTextarea(config.applicationEmails || []),
+ mode: 'replace',
+ });
+ setDialogState({
+ mode: 'edit',
+ config,
+ });
+ },
+ []
+ );
+
+ const closeDialog = useCallback(() => {
+ setDialogState(null);
+ setFormState(DEFAULT_FORM_STATE);
+ }, []);
+
+ const closeDeleteDialog = useCallback(() => {
+ setDeleteConfig(null);
+ }, []);
+
+ const handleRefresh = useCallback(async () => {
+ await mutate();
+ }, [mutate]);
+
+ const handleCopyConfigId = useCallback(async (configId: string) => {
+ try {
+ await navigator.clipboard.writeText(configId);
+ toast.success('Configuration ID copied to clipboard');
+ } catch {
+ toast.error('Failed to copy configuration ID');
+ }
+ }, []);
+
+ const handleSubmit = useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!dialogState) {
+ return;
+ }
+
+ const rawAdminCCEmails = formState.adminCCEmails.trim();
+ const parsedAdminCCEmails = rawAdminCCEmails
+ ? parseEmailList(rawAdminCCEmails)
+ : [];
+ const invalidAdminEmails = findInvalidEmails(parsedAdminCCEmails);
+
+ if (dialogState.mode === 'create' && !parsedAdminCCEmails.length) {
+ toast.error('Add at least one valid admin CC email');
+ return;
+ }
+
+ if (rawAdminCCEmails && invalidAdminEmails.length > 0) {
+ toast.error(
+ `Invalid admin CC email${invalidAdminEmails.length > 1 ? 's' : ''}: ${invalidAdminEmails.join(', ')}`
+ );
+ return;
+ }
+
+ const rawApplicationEmails = formState.applicationEmails.trim();
+ const parsedApplicationEmails = rawApplicationEmails
+ ? parseEmailList(rawApplicationEmails)
+ : [];
+ const invalidApplicationEmails = findInvalidEmails(
+ parsedApplicationEmails
+ );
+
+ if (rawApplicationEmails && invalidApplicationEmails.length > 0) {
+ toast.error(
+ `Invalid application email${invalidApplicationEmails.length > 1 ? 's' : ''}: ${invalidApplicationEmails.join(', ')}`
+ );
+ return;
+ }
+
+ if (
+ dialogState.mode === 'edit' &&
+ (formState.mode === 'add' || formState.mode === 'remove') &&
+ !parsedApplicationEmails.length
+ ) {
+ toast.error('Add at least one application email to update');
+ return;
+ }
+
+ const payload: {
+ adminCCEmails?: string;
+ applicationEmails?: string[];
+ addApplicationEmails?: string[];
+ removeApplicationEmails?: string[];
+ } = {};
+
+ if (parsedAdminCCEmails.length > 0) {
+ payload.adminCCEmails = formatEmailInput(parsedAdminCCEmails);
+ }
+
+ if (dialogState.mode === 'create') {
+ if (parsedApplicationEmails.length > 0) {
+ payload.applicationEmails = parsedApplicationEmails;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const result =
+ await applicationEmailConfigService.createApplicationEmailConfiguration(
+ {
+ adminCCEmails:
+ payload.adminCCEmails ||
+ formatEmailInput(parsedAdminCCEmails),
+ applicationEmails: payload.applicationEmails,
+ }
+ );
+ toast.success(
+ result.message || 'Email configuration created successfully'
+ );
+ closeDialog();
+ await mutate();
+ } catch (submitError) {
+ toast.error(getUserFriendlyErrorMessage(submitError));
+ console.error(
+ 'Create email configuration error:',
+ sanitizeErrorForLogging(submitError)
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
+ if (!dialogState.config) {
+ return;
+ }
+
+ if (formState.mode === 'replace') {
+ payload.applicationEmails = parsedApplicationEmails;
+ } else if (formState.mode === 'add') {
+ payload.addApplicationEmails = parsedApplicationEmails;
+ } else if (formState.mode === 'remove') {
+ payload.removeApplicationEmails = parsedApplicationEmails;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const result =
+ await applicationEmailConfigService.updateApplicationEmailConfiguration(
+ dialogState.config._id,
+ payload
+ );
+ toast.success(
+ result.message || 'Email configuration updated successfully'
+ );
+ closeDialog();
+ await mutate();
+ } catch (submitError) {
+ toast.error(getUserFriendlyErrorMessage(submitError));
+ console.error(
+ 'Update email configuration error:',
+ sanitizeErrorForLogging(submitError)
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [closeDialog, dialogState, formState, mutate]
+ );
+
+ const handleDeleteConfig = useCallback(async () => {
+ if (!deleteConfig) {
+ return;
+ }
+
+ setIsDeleting(true);
+ try {
+ const result =
+ await applicationEmailConfigService.deleteApplicationEmailConfiguration(
+ deleteConfig._id
+ );
+ toast.success(result.message || 'Email configuration deleted');
+ closeDeleteDialog();
+ await mutate();
+ } catch (deleteError) {
+ toast.error(getUserFriendlyErrorMessage(deleteError));
+ console.error(
+ 'Delete email configuration error:',
+ sanitizeErrorForLogging(deleteError)
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ }, [closeDeleteDialog, deleteConfig, mutate]);
+
+ const summaryCards = useMemo(
+ () => [
+ {
+ title: 'Configurations',
+ value: configs.length,
+ description: 'Saved application email config records',
+ },
+ {
+ title: 'Admin CC recipients',
+ value: adminCCRecipientCount,
+ description: 'Admins copied on automated alerts',
+ },
+ {
+ title: 'Application emails',
+ value: applicationEmailCount,
+ description: 'App-account emails currently tracked',
+ },
+ {
+ title: 'Non-empty configs',
+ value: configsWithApplications,
+ description: 'Configs with at least one application email',
+ },
+ ],
+ [
+ adminCCRecipientCount,
+ applicationEmailCount,
+ configs.length,
+ configsWithApplications,
+ ]
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ key: '_id',
+ label: 'Configuration',
+ minWidth: '220px',
+ render: (_value: unknown, item: EmailConfigRow) => (
+
+
+
+ Email config
+
+
+ {shortenId(item._id)}
+
+
+
+ handleCopyConfigId(item._id)}
+ className="h-8 w-8 p-1"
+ aria-label="Copy configuration ID"
+ >
+
+
+
+
+ ),
+ },
+ {
+ key: 'adminCCEmails',
+ label: 'Admin CC emails',
+ minWidth: '260px',
+ render: (_value: unknown, item: EmailConfigRow) =>
+ renderEmailChips(
+ parseEmailList(item.adminCCEmails || ''),
+ 'No admin CC emails'
+ ),
+ },
+ {
+ key: 'applicationEmails',
+ label: 'Application emails',
+ minWidth: '280px',
+ render: (_value: unknown, item: EmailConfigRow) =>
+ renderEmailChips(
+ item.applicationEmails || [],
+ 'No application emails'
+ ),
+ },
+ {
+ key: 'updatedAt',
+ label: 'Updated',
+ minWidth: '160px',
+ cellClassName: 'whitespace-nowrap',
+ render: (_value: unknown, item: EmailConfigRow) => {
+ const updatedValue = item.updatedAt || item.createdAt || '';
+
+ if (!updatedValue) {
+ return - ;
+ }
+
+ return (
+
+ {formatDate(updatedValue, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+ );
+ },
+ },
+ {
+ key: 'actions',
+ label: 'Actions',
+ minWidth: '108px',
+ cellClassName: 'whitespace-nowrap',
+ render: (_value: unknown, item: EmailConfigRow) => (
+
+
+ openEditDialog(item)}
+ className="h-8 w-8 p-1"
+ aria-label={`Edit configuration ${shortenId(item._id)}`}
+ >
+
+
+
+
+ setDeleteConfig(item)}
+ className="h-8 w-8 p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
+ aria-label={`Delete configuration ${shortenId(item._id)}`}
+ >
+
+
+
+
+ ),
+ },
+ ],
+ [handleCopyConfigId, openEditDialog]
+ );
+
+ const pageAction = (
+
+
+ Refresh
+
+
+ Create configuration
+
+
+ );
+
+ return (
+ !!user?.email?.toLowerCase().endsWith('@airqo.net')}
+ accessDeniedTitle="Access Restricted"
+ accessDeniedMessage="You need the SUPER_ADMIN permission and an @airqo.net super-admin account to manage email configs."
+ >
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+
+
+
+ Failed to load email configurations
+
+
+ {getUserFriendlyErrorMessage(error)}
+
+
+
+
+ Try again
+
+
+
+
+ ) : (
+
+
+ {summaryCards.map(card => (
+
+ {card.title}
+
+ {card.value}
+
+
+ {card.description}
+
+
+ ))}
+
+
+ {configs.length === 0 ? (
+
}
+ action={{
+ label: 'Create configuration',
+ onClick: openCreateDialog,
+ variant: 'filled',
+ }}
+ />
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ This will permanently remove the configuration for{' '}
+
+ {deleteConfig ? shortenId(deleteConfig._id) : ''}
+
+ . Any automated alerts using this config will stop copying the
+ configured admin CC recipients.
+
+
+ {deleteConfig && (
+
+
+
+ Admin CC emails
+
+
+ {parseEmailList(deleteConfig.adminCCEmails || '').length}
+
+
+
+
+ Application emails
+
+
+ {(deleteConfig.applicationEmails || []).length}
+
+
+
+ )}
+
+
+
+ Cancel
+
+
+ Delete configuration
+
+
+
+
+
+
+ );
+};
+
+export default EmailConfigContent;
diff --git a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx
index a8e84c9248..dd98077b1b 100644
--- a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx
@@ -10,6 +10,7 @@ import PageHeading from '@/shared/components/ui/page-heading';
import { TextInput } from '@/shared/components/ui/text-input';
import { formatWithPattern } from '@/shared/utils/dateUtils';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { refreshWithToast } from '@/shared/utils/refreshWithToast';
import {
useOrganizationRequests,
useApproveOrganizationRequest,
@@ -104,23 +105,21 @@ const OrganizationRequestsPage = () => {
// Apply search filter
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
- filtered = filtered.filter(
- request => {
- const projectName = getProjectName(request).toLowerCase();
- const city = (request.city || '').toLowerCase();
- const legacySlug = (request.organization_slug || '').toLowerCase();
- const partner = getPartnerName(request).toLowerCase();
-
- return (
- projectName.includes(searchLower) ||
- request.contact_name.toLowerCase().includes(searchLower) ||
- request.contact_email.toLowerCase().includes(searchLower) ||
- city.includes(searchLower) ||
- partner.includes(searchLower) ||
- legacySlug.includes(searchLower)
- );
- }
- );
+ filtered = filtered.filter(request => {
+ const projectName = getProjectName(request).toLowerCase();
+ const city = (request.city || '').toLowerCase();
+ const legacySlug = (request.organization_slug || '').toLowerCase();
+ const partner = getPartnerName(request).toLowerCase();
+
+ return (
+ projectName.includes(searchLower) ||
+ request.contact_name.toLowerCase().includes(searchLower) ||
+ request.contact_email.toLowerCase().includes(searchLower) ||
+ city.includes(searchLower) ||
+ partner.includes(searchLower) ||
+ legacySlug.includes(searchLower)
+ );
+ });
}
return filtered;
@@ -151,9 +150,17 @@ const OrganizationRequestsPage = () => {
: null;
}, [confirmDialog, requests]);
- const handleRefresh = () => {
- mutate();
- };
+ const handleRefresh = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => mutate(),
+ 'Organization requests refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Refresh organization requests error:', error);
+ }
+ }, [mutate]);
const handleApprove = useCallback((requestId: string) => {
setConfirmDialog({ type: 'approve', id: requestId });
@@ -367,23 +374,24 @@ const OrganizationRequestsPage = () => {
variant={activeTab === 'pending' ? 'filled' : 'outlined'}
onClick={() => setActiveTab('pending')}
>
- Pending ({requests.filter(r => r.status === 'pending').length})
+ Pending ({requests.filter(r => r.status === 'pending').length}
+ )
setActiveTab('approved')}
>
- Approved ({requests.filter(r => r.status === 'approved').length}
- )
+ Approved (
+ {requests.filter(r => r.status === 'approved').length})
setActiveTab('rejected')}
>
- Rejected ({requests.filter(r => r.status === 'rejected').length}
- )
+ Rejected (
+ {requests.filter(r => r.status === 'rejected').length})
@@ -447,7 +455,8 @@ const OrganizationRequestsPage = () => {
>
Are you sure you want to {confirmDialog?.type} the request from{' '}
- {currentRequest ? getProjectName(currentRequest) : 'this project'}?
+ {currentRequest ? getProjectName(currentRequest) : 'this project'}
+ ?
{confirmDialog?.type === 'reject' && (
{
+ const limit = 100;
+ let skip = 0;
+ let results: BlockedAsn[] = [];
+
+ while (true) {
+ const response = await adminService.getBlockedASNs({ skip, limit });
+ const batch = response.blocked_asns || [];
+ results = results.concat(batch);
+ if (batch.length < limit) {
+ break;
+ }
+ skip += limit;
+ }
+
+ return results;
+};
+
+const fetchAllFlaggedTokens = async () => {
+ const limit = 100;
+ let skip = 0;
+ let results: FlaggedToken[] = [];
+
+ while (true) {
+ const response = await adminService.getFlaggedTokens({ skip, limit });
+ const batch = response.flagged_tokens || [];
+ results = results.concat(batch);
+ if (batch.length < limit) {
+ break;
+ }
+ skip += limit;
+ }
+
+ return results;
+};
+
+interface StringListEditorProps {
+ label: string;
+ description: string;
+ values: string[];
+ errors: string[];
+ onChange: (index: number, value: string) => void;
+ onAdd: () => void;
+ onRemove: (index: number) => void;
+ placeholder: string;
+}
+
+const StringListEditor: React.FC = ({
+ label,
+ description,
+ values,
+ errors,
+ onChange,
+ onAdd,
+ onRemove,
+ placeholder,
+}) => {
+ return (
+
+
+
{label}
+
{description}
+
+
+
+ {values.map((value, index) => (
+
+
+
onChange(index, e.target.value)}
+ placeholder={placeholder}
+ className="w-full"
+ />
+ {errors[index] && (
+
+ {errors[index]}
+
+ )}
+
+ {values.length > 1 && (
+
onRemove(index)}
+ className="px-3"
+ >
+ Remove
+
+ )}
+
+ ))}
+
+
+ + Add Row
+
+
+
+ );
+};
+
+interface BlockedAsnDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ entry: BlockedAsn | null;
+ onSuccess: () => void;
+}
+
+const BlockedAsnDialog: React.FC = ({
+ isOpen,
+ onClose,
+ entry,
+ onSuccess,
+}) => {
+ const [provider, setProvider] = useState('');
+ const [asn, setAsn] = useState('');
+ const [cidrRanges, setCidrRanges] = useState(['']);
+ const [cidrErrors, setCidrErrors] = useState(['']);
+ const [reason, setReason] = useState('');
+ const [active, setActive] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ setProvider(entry?.provider || '');
+ setAsn(entry?.asn || '');
+ const cidrs = entry?.cidr_ranges || [];
+ setCidrRanges(cidrs.length > 0 ? cidrs : ['']);
+ setCidrErrors(cidrs.length > 0 ? cidrs.map(() => '') : ['']);
+ setReason(entry?.reason || '');
+ setActive(entry?.active ?? true);
+ }, [entry, isOpen]);
+
+ const handleAddCidr = () => {
+ setCidrRanges(previous => [...previous, '']);
+ setCidrErrors(previous => [...previous, '']);
+ };
+
+ const handleRemoveCidr = (index: number) => {
+ if (cidrRanges.length <= 1) {
+ return;
+ }
+
+ setCidrRanges(previous => previous.filter((_, i) => i !== index));
+ setCidrErrors(previous => previous.filter((_, i) => i !== index));
+ };
+
+ const handleCidrChange = (index: number, value: string) => {
+ setCidrRanges(previous => {
+ const next = [...previous];
+ next[index] = value;
+ return next;
+ });
+
+ setCidrErrors(previous => {
+ const next = [...previous];
+ next[index] = '';
+ return next;
+ });
+ };
+
+ const handleSubmit = async () => {
+ const normalizedProvider = provider.trim();
+ const normalizedAsn = asn.trim().toUpperCase();
+ const normalizedCidrs = cidrRanges
+ .map(value => value.trim())
+ .filter(Boolean);
+ const normalizedReason = reason.trim();
+
+ if (!normalizedProvider) {
+ toast.error('Provider is required');
+ return;
+ }
+
+ if (!normalizedAsn && normalizedCidrs.length === 0) {
+ toast.error('Provide either an ASN or at least one CIDR range');
+ return;
+ }
+
+ if (normalizedAsn && !isValidAsn(normalizedAsn)) {
+ toast.error('Enter a valid ASN in the format AS12345');
+ return;
+ }
+
+ const nextCidrErrors = cidrRanges.map(value => {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return '';
+ }
+ return isValidCidrNotation(trimmed)
+ ? ''
+ : 'Enter a valid IPv4 CIDR range, for example 192.0.2.0/24';
+ });
+ setCidrErrors(nextCidrErrors);
+
+ if (nextCidrErrors.some(Boolean)) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const payload: CreateBlockedAsnRequest = {
+ provider: normalizedProvider,
+ asn: normalizedAsn || undefined,
+ cidr_ranges: normalizedCidrs.length > 0 ? normalizedCidrs : undefined,
+ reason: normalizedReason || undefined,
+ active,
+ };
+
+ await adminService.createBlockedASN(payload);
+ toast.success(
+ entry
+ ? 'Blocked range updated successfully'
+ : 'Blocked range created successfully'
+ );
+ onSuccess();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Blocked ASN save error:', sanitizeErrorForLogging(error));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isSubmitting) {
+ onClose();
+ }
+ };
+
+ return (
+
+
+
setProvider(e.target.value)}
+ placeholder="Amazon Web Services"
+ description="Human-readable provider name, e.g. Cloudflare or DigitalOcean."
+ required
+ />
+
+
+
setAsn(e.target.value)}
+ placeholder="AS16509"
+ description="Optional if CIDR ranges are provided."
+ />
+
+
+
+
+
+
Active
+
+ Inactive entries remain in the list but are not enforced.
+
+
+
+
+
+
+
+
+
setReason(e.target.value)}
+ placeholder="AWS data-center range"
+ rows={4}
+ />
+
+
+ );
+};
+
+interface ResolveFlaggedTokenDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ item: FlaggedToken | null;
+ onSuccess: () => void;
+}
+
+const ResolveFlaggedTokenDialog: React.FC = ({
+ isOpen,
+ onClose,
+ item,
+ onSuccess,
+}) => {
+ const [note, setNote] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ setNote(item?.resolution_note || '');
+ }
+ }, [isOpen, item]);
+
+ const handleClose = () => {
+ if (!isSubmitting) {
+ onClose();
+ }
+ };
+
+ const handleResolve = async () => {
+ if (!item) {
+ toast.error('Flagged token data is unavailable');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await adminService.resolveFlaggedToken(item._id, {
+ note: note.trim() || undefined,
+ });
+ toast.success('Flagged token resolved successfully');
+ onSuccess();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error(
+ 'Resolve flagged token error:',
+ sanitizeErrorForLogging(error)
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
IP
+
{item?.ip || '—'}
+
+
+
Service
+
{item?.service || '—'}
+
+
+
Action taken
+
{item?.action_taken || '—'}
+
+
+
Flagged at
+
{item?.flagged_at ? formatDate(item.flagged_at) : '—'}
+
+
+
+
+
setNote(e.target.value)}
+ placeholder="Investigated automated scanner. Token owner notified and key rotated."
+ rows={5}
+ />
+
+
+ );
+};
+
+const SecurityPageContent: React.FC = () => {
+ const [activeTab, setActiveTab] = useState('blocked-asns');
+ const [blockedFilter, setBlockedFilter] = useState<
+ 'all' | 'active' | 'inactive'
+ >('all');
+ const [flaggedFilter, setFlaggedFilter] = useState<
+ 'all' | 'open' | 'resolved'
+ >('open');
+ const [blockedDialogEntry, setBlockedDialogEntry] =
+ useState(null);
+ const [blockedDialogOpen, setBlockedDialogOpen] = useState(false);
+ const [resolveDialogEntry, setResolveDialogEntry] =
+ useState(null);
+ const [resolveDialogOpen, setResolveDialogOpen] = useState(false);
+ const [deleteDialogEntry, setDeleteDialogEntry] = useState(
+ null
+ );
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+ const {
+ data: blockedAsns,
+ error: blockedError,
+ isLoading: blockedLoading,
+ mutate: mutateBlocked,
+ } = useSWR('system/security/blocked-asns', fetchAllBlockedAsns, {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ });
+
+ const {
+ data: flaggedTokens,
+ error: flaggedError,
+ isLoading: flaggedLoading,
+ mutate: mutateFlagged,
+ } = useSWR('system/security/flagged-tokens', fetchAllFlaggedTokens, {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ });
+
+ const blockedSummary = useMemo(() => {
+ const items = blockedAsns || [];
+ return {
+ total: items.length,
+ active: items.filter(item => item.active).length,
+ inactive: items.filter(item => !item.active).length,
+ };
+ }, [blockedAsns]);
+
+ const flaggedSummary = useMemo(() => {
+ const items = flaggedTokens || [];
+ return {
+ total: items.length,
+ open: items.filter(item => !item.resolved).length,
+ resolved: items.filter(item => item.resolved).length,
+ };
+ }, [flaggedTokens]);
+
+ const filteredBlockedRows = useMemo(() => {
+ const items = blockedAsns || [];
+ const filtered = items.filter(item => {
+ if (blockedFilter === 'active') return item.active;
+ if (blockedFilter === 'inactive') return !item.active;
+ return true;
+ });
+
+ return filtered.map(item => ({ ...item, id: item._id }));
+ }, [blockedAsns, blockedFilter]);
+
+ const filteredFlaggedRows = useMemo(() => {
+ const items = flaggedTokens || [];
+ const filtered = items.filter(item => {
+ if (flaggedFilter === 'open') return !item.resolved;
+ if (flaggedFilter === 'resolved') return item.resolved;
+ return true;
+ });
+
+ return filtered.map(item => ({ ...item, id: item._id }));
+ }, [flaggedTokens, flaggedFilter]);
+
+ const handleEditBlockedAsn = useCallback((entry: BlockedAsn) => {
+ setBlockedDialogEntry(entry);
+ setBlockedDialogOpen(true);
+ }, []);
+
+ const handleDeleteBlockedAsn = useCallback((entry: BlockedAsn) => {
+ setDeleteDialogEntry(entry);
+ setDeleteDialogOpen(true);
+ }, []);
+
+ const handleResolveToken = useCallback((entry: FlaggedToken) => {
+ setResolveDialogEntry(entry);
+ setResolveDialogOpen(true);
+ }, []);
+
+ const handleCreateBlockedAsn = useCallback(() => {
+ setBlockedDialogEntry(null);
+ setBlockedDialogOpen(true);
+ }, []);
+
+ const handleBlockedDialogSuccess = useCallback(async () => {
+ setBlockedDialogOpen(false);
+ setBlockedDialogEntry(null);
+ await mutateBlocked();
+ }, [mutateBlocked]);
+
+ const handleResolveDialogSuccess = useCallback(async () => {
+ setResolveDialogOpen(false);
+ setResolveDialogEntry(null);
+ await mutateFlagged();
+ }, [mutateFlagged]);
+
+ const handleRefreshBlocked = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => mutateBlocked(),
+ 'Blocked ASN rules refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ }
+ }, [mutateBlocked]);
+
+ const handleRefreshFlagged = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => mutateFlagged(),
+ 'Flagged tokens refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ }
+ }, [mutateFlagged]);
+
+ const handleDeleteBlockedAsnConfirm = async () => {
+ if (!deleteDialogEntry) {
+ return;
+ }
+
+ try {
+ await adminService.deleteBlockedASN(deleteDialogEntry._id);
+ toast.success('Blocked ASN deleted successfully');
+ setDeleteDialogOpen(false);
+ setDeleteDialogEntry(null);
+ await mutateBlocked();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error(
+ 'Delete blocked ASN error:',
+ sanitizeErrorForLogging(error)
+ );
+ }
+ };
+
+ const blockedColumns = useMemo(
+ () => [
+ {
+ key: 'provider',
+ label: 'Provider',
+ minWidth: '220px',
+ render: (_value: unknown, item: BlockedAsnRow) => (
+
+
{item.provider}
+
+ {item.asn || 'ASN not provided'}
+
+
+ ),
+ },
+ {
+ key: 'cidr_ranges',
+ label: 'CIDR ranges',
+ minWidth: '260px',
+ render: (_value: unknown, item: BlockedAsnRow) => {
+ const cidrRanges = item.cidr_ranges ?? [];
+ const visible = cidrRanges.slice(0, 2);
+ const more = Math.max(0, cidrRanges.length - visible.length);
+ return (
+
+ {visible.map(range => (
+
+ {range}
+
+ ))}
+ {more > 0 && (
+
+ +{more} more
+
+ )}
+
+ );
+ },
+ },
+ {
+ key: 'reason',
+ label: 'Reason',
+ minWidth: '240px',
+ render: (value: unknown) => (
+
+ {String(value || '—')}
+
+ ),
+ },
+ {
+ key: 'active',
+ label: 'Status',
+ minWidth: '110px',
+ render: (value: unknown, item: BlockedAsnRow) => (
+
+ {item.active ? 'Active' : 'Inactive'}
+
+ ),
+ },
+ {
+ key: 'blockedAt',
+ label: 'Blocked',
+ minWidth: '170px',
+ render: (value: unknown) => (
+
+ {value ? formatDate(String(value)) : '—'}
+
+ ),
+ },
+ {
+ key: 'actions',
+ label: 'Actions',
+ minWidth: '120px',
+ render: (_value: unknown, item: BlockedAsnRow) => (
+
+
handleEditBlockedAsn(item)}
+ className="p-1 h-8 w-8"
+ aria-label={`Edit blocked range ${item.provider}`}
+ >
+
+
+
handleDeleteBlockedAsn(item)}
+ className="p-1 h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
+ aria-label={`Delete blocked range ${item.provider}`}
+ >
+
+
+
+ ),
+ },
+ ],
+ [handleDeleteBlockedAsn, handleEditBlockedAsn]
+ );
+
+ const flaggedColumns = useMemo(
+ () => [
+ {
+ key: 'token_suffix',
+ label: 'Token',
+ minWidth: '120px',
+ render: (value: unknown) => (
+
+ ****{String(value || '')}
+
+ ),
+ },
+ {
+ key: 'ip',
+ label: 'IP',
+ minWidth: '140px',
+ render: (value: unknown) => (
+ {String(value || '—')}
+ ),
+ },
+ {
+ key: 'user_agent',
+ label: 'User agent',
+ minWidth: '220px',
+ maxWidth: '320px',
+ render: (value: unknown) => (
+
+ {String(value || '—')}
+
+ ),
+ },
+ {
+ key: 'honeypot_path',
+ label: 'Path',
+ minWidth: '220px',
+ render: (value: unknown) => (
+
+ {String(value || '—')}
+
+ ),
+ },
+ {
+ key: 'service',
+ label: 'Service',
+ minWidth: '140px',
+ render: (value: unknown) => (
+
+ {String(value || '—')}
+
+ ),
+ },
+ {
+ key: 'action_taken',
+ label: 'Action',
+ minWidth: '120px',
+ render: (value: unknown) => (
+
+ {String(value || '—')}
+
+ ),
+ },
+ {
+ key: 'flagged_at',
+ label: 'Flagged',
+ minWidth: '170px',
+ render: (value: unknown) => (
+
+ {value ? formatDate(String(value)) : '—'}
+
+ ),
+ },
+ {
+ key: 'resolved',
+ label: 'Status',
+ minWidth: '110px',
+ render: (_value: unknown, item: FlaggedTokenRow) => (
+
+ {item.resolved ? 'Resolved' : 'Open'}
+
+ ),
+ },
+ {
+ key: 'actions',
+ label: 'Actions',
+ minWidth: '120px',
+ render: (_value: unknown, item: FlaggedTokenRow) => (
+
+ {!item.resolved ? (
+ handleResolveToken(item)}
+ >
+ Resolve
+
+ ) : (
+ Reviewed
+ )}
+
+ ),
+ },
+ ],
+ [handleResolveToken]
+ );
+
+ const summaryCards = [
+ {
+ title: 'Blocked entries',
+ value: blockedSummary.total,
+ description: `${blockedSummary.active} active · ${blockedSummary.inactive} inactive`,
+ },
+ {
+ title: 'Flagged tokens',
+ value: flaggedSummary.total,
+ description: `${flaggedSummary.open} open · ${flaggedSummary.resolved} resolved`,
+ },
+ ];
+
+ return (
+
+
+ Add Blocked ASN
+
+ ) : undefined
+ }
+ />
+
+
+ {summaryCards.map(card => (
+
+ {card.title}
+
+ {card.value}
+
+
+ {card.description}
+
+
+ ))}
+
+
+
+
+ setActiveTab('blocked-asns')}
+ >
+ ASN / CIDR Blocks
+
+ setActiveTab('flagged-tokens')}
+ >
+ Flagged Tokens
+
+
+
+
+ {activeTab === 'blocked-asns' ? (
+
+
+ setBlockedFilter('all')}
+ >
+ All ({blockedSummary.total})
+
+ setBlockedFilter('active')}
+ >
+ Active ({blockedSummary.active})
+
+ setBlockedFilter('inactive')}
+ >
+ Inactive ({blockedSummary.inactive})
+
+
+ Refresh
+
+
+
+
+
+ ) : (
+
+
+ setFlaggedFilter('open')}
+ >
+ Open ({flaggedSummary.open})
+
+ setFlaggedFilter('resolved')}
+ >
+ Resolved ({flaggedSummary.resolved})
+
+ setFlaggedFilter('all')}
+ >
+ All ({flaggedSummary.total})
+
+
+ Refresh
+
+
+
+
+
+ )}
+
+ setBlockedDialogOpen(false)}
+ entry={blockedDialogEntry}
+ onSuccess={handleBlockedDialogSuccess}
+ />
+
+ setResolveDialogOpen(false)}
+ item={resolveDialogEntry}
+ onSuccess={handleResolveDialogSuccess}
+ />
+
+ setDeleteDialogOpen(false)}
+ title="Delete blocked ASN"
+ size="md"
+ >
+
+
+ Are you sure you want to delete the block for{' '}
+ {deleteDialogEntry?.provider}
+ ? This will immediately remove the rule after the backend cache
+ expires.
+
+
+ setDeleteDialogOpen(false)}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+};
+
+const SystemSecurityPage: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+export default SystemSecurityPage;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx
new file mode 100644
index 0000000000..e6be36b103
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import React, { useCallback, useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import useSWR, { useSWRConfig } from 'swr';
+import { PermissionGuard } from '@/shared/components';
+import { Card, LoadingState } from '@/shared/components/ui';
+import type {
+ CreateSurveyRequest,
+ Survey,
+ UpdateSurveyRequest,
+} from '@/shared/types/api';
+import { surveyService } from '@/shared/services';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import SurveyForm from '../../components/SurveyForm';
+
+const SurveyEditPage: React.FC = () => {
+ const params = useParams<{ surveyId: string }>();
+ const router = useRouter();
+ const { mutate } = useSWRConfig();
+ const surveyId = params?.surveyId;
+
+ const {
+ data: survey,
+ error,
+ isLoading,
+ mutate: refreshSurvey,
+ } = useSWR(
+ surveyId ? `system/surveys/${surveyId}` : null,
+ () => surveyService.getSurveyById(surveyId as string),
+ {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+
+ useEffect(() => {
+ if (!surveyId) {
+ router.push('/system/surveys');
+ }
+ }, [router, surveyId]);
+
+ const handleCancel = useCallback(() => {
+ router.push(`/system/surveys/${surveyId}`);
+ }, [router, surveyId]);
+
+ const handleSubmit = useCallback(
+ async (payload: CreateSurveyRequest | UpdateSurveyRequest) => {
+ return surveyService.updateSurvey(
+ surveyId as string,
+ payload as UpdateSurveyRequest
+ );
+ },
+ [surveyId]
+ );
+
+ const handleSuccess = useCallback(
+ async (updatedSurvey: Survey) => {
+ await Promise.allSettled([
+ mutate('system/surveys'),
+ mutate(`system/surveys/${updatedSurvey._id}`),
+ refreshSurvey(),
+ ]);
+
+ router.replace(`/system/surveys/${updatedSurvey._id}`);
+ },
+ [mutate, refreshSurvey, router]
+ );
+
+ const surveyErrorMessage = error
+ ? getUserFriendlyErrorMessage(error)
+ : 'Survey not found.';
+
+ return (
+
+ {isLoading ? (
+
+ ) : error || !survey ? (
+
+
+
+ {surveyErrorMessage}
+
+
router.push('/system/surveys')}
+ className="text-sm font-medium text-primary hover:underline"
+ >
+ Back to surveys
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default SurveyEditPage;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx
new file mode 100644
index 0000000000..a7d4270c48
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx
@@ -0,0 +1,1132 @@
+'use client';
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import useSWR, { useSWRConfig } from 'swr';
+import jsPDF from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import { PermissionGuard } from '@/shared/components';
+import {
+ Button,
+ Card,
+ Dialog,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ LoadingState,
+ PageHeading,
+ toast,
+} from '@/shared/components/ui';
+import { ServerSideTable } from '@/shared/components/ui/server-side-table';
+import {
+ AqArrowLeft,
+ AqDownload01,
+ AqEdit05,
+ AqEye,
+ AqRefreshCw05,
+ AqTrash01,
+} from '@airqo/icons-react';
+import { surveyService } from '@/shared/services';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { refreshWithToast } from '@/shared/utils/refreshWithToast';
+import type { Survey, SurveyResponseItem } from '@/shared/types/api';
+import SurveyResponseDialog from '../components/SurveyResponseDialog';
+import {
+ formatDateTime,
+ formatDuration,
+ formatQuestionTypeLabel,
+ formatSurveyStatus,
+ getQuestionDistribution,
+ getSurveyTriggerLabel,
+ getTopAnswerEntries,
+ getTotalAnswerCount,
+ formatResponseValue,
+} from '../utils';
+
+type SurveyResponseRow = SurveyResponseItem & {
+ id: string;
+ [key: string]: unknown;
+};
+
+const RESPONSE_STATUS_TONES: Record = {
+ completed:
+ 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300',
+ skipped:
+ 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
+ partial: 'bg-blue-100 text-blue-800 dark:bg-blue-950/40 dark:text-blue-300',
+};
+
+const getResponseStatusTone = (value?: string) => {
+ const normalized = String(value || '').toLowerCase();
+ return (
+ RESPONSE_STATUS_TONES[normalized] ||
+ 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300'
+ );
+};
+
+const getRespondentLabel = (response: SurveyResponseItem) => {
+ if (response.user?.email) {
+ return response.user.email;
+ }
+
+ if (response.userId) {
+ return response.userId;
+ }
+
+ return response.isGuest ? 'Guest respondent' : 'Unknown respondent';
+};
+
+type SurveyResponseExportRow = {
+ respondent: string;
+ status: string;
+ answersSummary: string;
+ timeSpent: string;
+ submittedAt: string;
+ responseId: string;
+ surveyId: string;
+ userId: string;
+ deviceId: string;
+ [questionKey: string]: string;
+};
+
+const escapeCsvValue = (value: string): string => {
+ const normalized = String(value ?? '');
+ const firstNonWhitespace = normalized.trimStart().charAt(0);
+ const startsWithFormulaChar = ['=', '+', '-', '@'].includes(
+ firstNonWhitespace
+ );
+ const startsWithTabOrCarriageReturn =
+ normalized.charAt(0) === '\t' || normalized.charAt(0) === '\r';
+
+ const prefixed =
+ startsWithFormulaChar || startsWithTabOrCarriageReturn
+ ? `'${normalized}`
+ : normalized;
+
+ return prefixed.replace(/"/g, '""');
+};
+
+const buildSurveyResponseExportRows = (
+ survey: Survey | null | undefined,
+ responses: SurveyResponseItem[]
+): SurveyResponseExportRow[] => {
+ return responses.map(response => {
+ const base: SurveyResponseExportRow = {
+ respondent: getRespondentLabel(response),
+ status: formatQuestionTypeLabel(response.status || 'unknown'),
+ answersSummary: `${response.answerCount ?? response.answers.length} of ${survey?.questions?.length ?? '?'} answered`,
+ timeSpent: formatDuration(response.timeToComplete),
+ submittedAt: formatDateTime(response.completedAt || response.createdAt),
+ responseId: response._id,
+ surveyId: response.surveyId,
+ userId: response.userId || 'Not tracked',
+ deviceId: response.deviceId || 'Not tracked',
+ };
+
+ response.answers.forEach(answer => {
+ const question = survey?.questions.find(
+ item => item.id === answer.questionId
+ );
+ const label = question?.question || answer.questionId;
+ base[`Q: ${label}`] = formatResponseValue(answer.answer);
+ });
+
+ return base;
+ });
+};
+
+const exportSurveyResponsesAsCsv = (
+ survey: Survey | null | undefined,
+ responses: SurveyResponseItem[]
+) => {
+ const rows = buildSurveyResponseExportRows(survey, responses);
+ const questionColumns = survey?.questions?.map(q => `Q: ${q.question}`) || [];
+ const headers = [
+ 'Respondent',
+ 'Status',
+ 'Answers summary',
+ 'Time spent',
+ 'Submitted at',
+ 'Response ID',
+ 'Survey ID',
+ 'User ID',
+ 'Device ID',
+ ...questionColumns,
+ ];
+
+ const csvBody = rows.map(row => {
+ const base = [
+ row.respondent,
+ row.status,
+ row.answersSummary,
+ row.timeSpent,
+ row.submittedAt,
+ row.responseId,
+ row.surveyId,
+ row.userId,
+ row.deviceId,
+ ];
+ questionColumns.forEach(col => {
+ base.push(String(row[col] || ''));
+ });
+ return base;
+ });
+
+ const csv = [headers, ...csvBody]
+ .map(row => row.map(value => `"${escapeCsvValue(value)}"`).join(','))
+ .join('\n');
+
+ const fileName = `survey-responses-${
+ survey?.title
+ ?.toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'export'
+ }.csv`;
+
+ const blob = new Blob(['\uFEFF' + csv], {
+ type: 'text/csv;charset=utf-8;',
+ });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
+};
+
+const exportSurveyResponsesAsPdf = (
+ survey: Survey | null | undefined,
+ responses: SurveyResponseItem[]
+) => {
+ const rows = buildSurveyResponseExportRows(survey, responses);
+ const doc = new jsPDF({ unit: 'pt', format: 'a4' });
+ const generatedAt = new Date().toLocaleString();
+
+ doc.setFontSize(18);
+ doc.setFont('helvetica', 'bold');
+ doc.text('AirQo Survey Responses', 40, 50);
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.text(`Survey: ${survey?.title || 'Survey responses'}`, 40, 70);
+ doc.text(`Survey ID: ${survey?._id || 'Not available'}`, 40, 84);
+ doc.text(`Total responses: ${responses.length}`, 40, 98);
+ doc.text(`Generated on: ${generatedAt}`, 40, 112);
+
+ autoTable(doc, {
+ head: [
+ [
+ 'Respondent',
+ 'Status',
+ 'Answers',
+ 'Time spent',
+ 'Submitted at',
+ 'Response ID',
+ 'Device ID',
+ ],
+ ],
+ body: rows.map(row => [
+ row.respondent,
+ row.status,
+ row.answersSummary,
+ row.timeSpent,
+ row.submittedAt,
+ row.responseId,
+ row.deviceId,
+ ]),
+ startY: 130,
+ styles: {
+ fontSize: 8.5,
+ cellPadding: 4,
+ overflow: 'linebreak',
+ valign: 'top',
+ },
+ headStyles: {
+ fillColor: [22, 78, 99],
+ textColor: [255, 255, 255],
+ fontStyle: 'bold',
+ },
+ alternateRowStyles: {
+ fillColor: [245, 247, 250],
+ },
+ margin: { left: 40, right: 40 },
+ });
+
+ const pageCount = doc.getNumberOfPages();
+ for (let index = 1; index <= pageCount; index += 1) {
+ doc.setPage(index);
+ doc.setFontSize(9);
+ doc.text(
+ `Page ${index} of ${pageCount}`,
+ 40,
+ doc.internal.pageSize.height - 30
+ );
+ }
+
+ const fileName = `survey-responses-${
+ survey?.title
+ ?.toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'export'
+ }.pdf`;
+
+ doc.save(fileName);
+};
+
+const SurveyDetailsPage: React.FC = () => {
+ const params = useParams<{ surveyId: string }>();
+ const router = useRouter();
+ const { mutate: globalMutate } = useSWRConfig();
+ const surveyId = params?.surveyId;
+
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [selectedResponse, setSelectedResponse] =
+ useState(null);
+
+ const {
+ data: survey,
+ error: surveyError,
+ isLoading: surveyLoading,
+ mutate: mutateSurvey,
+ } = useSWR(
+ surveyId ? `system/surveys/${surveyId}` : null,
+ () => surveyService.getSurveyById(surveyId as string),
+ {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+
+ const {
+ data: stats,
+ error: statsError,
+ isLoading: statsLoading,
+ mutate: mutateStats,
+ } = useSWR(
+ surveyId ? `system/surveys/stats/${surveyId}` : null,
+ () => surveyService.getSurveyStats(surveyId as string),
+ {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+
+ const {
+ data: responses = [],
+ error: responsesError,
+ isLoading: responsesLoading,
+ mutate: mutateResponses,
+ } = useSWR(
+ 'system/surveys/responses',
+ () => surveyService.getSurveyResponses(),
+ {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+
+ useEffect(() => {
+ if (!surveyId) {
+ router.push('/system/surveys');
+ }
+ }, [router, surveyId]);
+
+ const currentSurvey = survey as Survey | undefined;
+
+ const filteredResponses = useMemo(() => {
+ return responses
+ .filter(response => response.surveyId === surveyId)
+ .slice()
+ .sort((left, right) => {
+ const leftTime = new Date(left.completedAt || left.createdAt).getTime();
+ const rightTime = new Date(
+ right.completedAt || right.createdAt
+ ).getTime();
+ return rightTime - leftTime;
+ });
+ }, [responses, surveyId]);
+
+ const responseRows = useMemo(
+ () =>
+ filteredResponses.map(response => ({
+ ...response,
+ id: response._id,
+ })),
+ [filteredResponses]
+ );
+
+ const overviewSummary = useMemo(() => {
+ if (!currentSurvey) {
+ return [];
+ }
+
+ return [
+ {
+ label: 'Question count',
+ value: currentSurvey.questionCount ?? currentSurvey.questions.length,
+ },
+ {
+ label: 'Required questions',
+ value:
+ currentSurvey.requiredQuestionCount ??
+ currentSurvey.questions.filter(question => question.isRequired)
+ .length,
+ },
+ {
+ label: 'Estimated time',
+ value: formatDuration(currentSurvey.timeToComplete),
+ },
+ {
+ label: 'Survey status',
+ value: formatSurveyStatus(
+ currentSurvey.isActive,
+ currentSurvey.surveyStatus,
+ currentSurvey.isExpired
+ ).label,
+ },
+ ];
+ }, [currentSurvey]);
+
+ const refreshSurveyData = useCallback(async () => {
+ const results = await Promise.allSettled([
+ mutateSurvey(),
+ mutateStats(),
+ mutateResponses(),
+ globalMutate('system/surveys'),
+ ]);
+
+ if (!results.some(result => result.status === 'fulfilled')) {
+ throw new Error('Unable to refresh survey data');
+ }
+ }, [globalMutate, mutateResponses, mutateStats, mutateSurvey]);
+
+ const handleBack = useCallback(() => {
+ router.push('/system/surveys');
+ }, [router]);
+
+ const handleRefresh = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ refreshSurveyData,
+ 'Survey data refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ }
+ }, [refreshSurveyData]);
+
+ const handleEditClick = useCallback(() => {
+ if (surveyId) {
+ router.push(`/system/surveys/${surveyId}/edit`);
+ }
+ }, [router, surveyId]);
+
+ const handleDeleteSurvey = useCallback(async () => {
+ if (!currentSurvey) {
+ return;
+ }
+
+ setIsDeleting(true);
+ try {
+ await surveyService.deleteSurvey(currentSurvey._id);
+ toast.success('Survey deleted successfully');
+ setDeleteDialogOpen(false);
+ await Promise.allSettled([
+ globalMutate('system/surveys'),
+ mutateSurvey(),
+ mutateStats(),
+ mutateResponses(),
+ ]);
+ router.push('/system/surveys');
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ } finally {
+ setIsDeleting(false);
+ }
+ }, [
+ currentSurvey,
+ globalMutate,
+ mutateResponses,
+ mutateStats,
+ mutateSurvey,
+ router,
+ ]);
+
+ const handleExportCsv = useCallback(() => {
+ exportSurveyResponsesAsCsv(currentSurvey, filteredResponses);
+ }, [currentSurvey, filteredResponses]);
+
+ const handleExportPdf = useCallback(() => {
+ exportSurveyResponsesAsPdf(currentSurvey, filteredResponses);
+ }, [currentSurvey, filteredResponses]);
+
+ const responseColumns = useMemo(
+ () => [
+ {
+ key: 'respondent',
+ label: 'Respondent',
+ minWidth: '200px',
+ render: (_value: unknown, item: SurveyResponseRow) => (
+
+
+ {getRespondentLabel(item)}
+
+
+ {item.isGuest
+ ? 'Guest response'
+ : item.user
+ ? 'Registered user'
+ : 'No profile'}
+
+
+ ),
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ minWidth: '110px',
+ render: (value: unknown) => (
+
+ {formatQuestionTypeLabel(String(value || 'unknown'))}
+
+ ),
+ },
+ {
+ key: 'answers',
+ label: 'Answers',
+ minWidth: '140px',
+ render: (_value: unknown, item: SurveyResponseRow) => {
+ const answerCount = item.answerCount ?? item.answers.length;
+ const totalQuestions = currentSurvey?.questions?.length ?? '?';
+
+ return (
+
+ {answerCount} of {totalQuestions} answered
+
+ );
+ },
+ },
+ {
+ key: 'deviceId',
+ label: 'Device',
+ minWidth: '140px',
+ render: (value: unknown) => (
+
+ {String(value || 'Not tracked')}
+
+ ),
+ },
+ {
+ key: 'timeToComplete',
+ label: 'Time spent',
+ minWidth: '100px',
+ render: (value: unknown) => (
+
+ {formatDuration(value as number | null | undefined)}
+
+ ),
+ },
+ {
+ key: 'submitted',
+ label: 'Submitted',
+ minWidth: '160px',
+ render: (_value: unknown, item: SurveyResponseRow) => (
+
+ {formatDateTime(item.completedAt || item.createdAt)}
+
+ ),
+ },
+ {
+ key: 'actions',
+ label: '',
+ minWidth: '80px',
+ render: (_value: unknown, item: SurveyResponseRow) => (
+ setSelectedResponse(item)}
+ >
+ View
+
+ ),
+ },
+ ],
+ [currentSurvey]
+ );
+
+ if (surveyLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (surveyError || !currentSurvey) {
+ return (
+
+
+
+ Back to surveys
+
+
+
+ {surveyError
+ ? getUserFriendlyErrorMessage(surveyError)
+ : 'Survey not found.'}
+
+
+
+
+ );
+ }
+
+ const status = formatSurveyStatus(
+ currentSurvey.isActive,
+ currentSurvey.surveyStatus,
+ currentSurvey.isExpired
+ );
+ const triggerLabel = getSurveyTriggerLabel(currentSurvey.trigger);
+
+ return (
+
+
+
+
+ Back
+
+
+
+
+
+ Refresh
+
+
+ Edit survey
+
+ setDeleteDialogOpen(true)}
+ >
+ Delete survey
+
+
+ }
+ >
+
+
+ {status.label}
+
+
+ {currentSurvey.questionCount ?? currentSurvey.questions.length}{' '}
+ questions
+
+
+ {formatDuration(currentSurvey.timeToComplete)}
+
+
+ Expires {formatDateTime(currentSurvey.expiresAt)}
+
+
+
+
+
+
+
+
+
+
+ Survey overview
+
+
+ Basic configuration and lifecycle details.
+
+
+
+ {triggerLabel}
+
+
+
+
+ {overviewSummary.map(item => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+ Dates
+
+
+
+
Created
+
+ {formatDateTime(currentSurvey.createdAt)}
+
+
+
+
Updated
+
+ {formatDateTime(currentSurvey.updatedAt)}
+
+
+
+
+
+
+
+
+
+
+
+ Questions
+
+
+ The survey structure and the strongest answer patterns.
+
+
+
+ {currentSurvey.questions.length} total
+
+
+
+
+ {currentSurvey.questions.length > 0 ? (
+ currentSurvey.questions.map((question, index) => {
+ const distribution = getQuestionDistribution(
+ stats?.answerDistribution,
+ question.id
+ );
+ const topAnswers = getTopAnswerEntries(distribution);
+ const maxCount = topAnswers[0]?.[1] || 0;
+ const totalAnswers = getTotalAnswerCount(distribution);
+
+ return (
+
+
+
+
+ Question {index + 1}
+
+
+ {formatQuestionTypeLabel(question.type)}
+
+
+ {question.isRequired ? 'Required' : 'Optional'}
+
+
+
+ {question.question}
+
+
+ Question ID: {question.id}
+
+
+ {question.type === 'multipleChoice' &&
+ question.options.length > 0 ? (
+
+
+ Options
+
+
+ {question.options.map(option => (
+
+ {option}
+
+ ))}
+
+
+ ) : question.type === 'rating' ||
+ question.type === 'scale' ? (
+
+
+ Range
+
+
+ {question.minValue ?? '1'} to{' '}
+ {question.maxValue ?? '5'}
+
+
+ ) : question.type === 'yesNo' ? (
+
+
+ Response format
+
+
+ Fixed Yes / No choice
+
+
+ ) : (
+
+
+ Response format
+
+
+ Free text response
+
+ {question.placeholder && (
+
+ Placeholder: {question.placeholder}
+
+ )}
+
+ )}
+
+ {topAnswers.length > 0 && (
+
+
+
+ Top responses
+
+
+ {totalAnswers} recorded
+
+
+
+ {topAnswers.map(([answer, count]) => (
+
+
+
+ {answer}
+
+
+ {count}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+ })
+ ) : (
+
+ This survey does not have any questions yet.
+
+ )}
+
+
+
+
+
+
+
+
+
+ Survey stats
+
+
+ Response health and completion performance.
+
+
+
+ Analytics
+
+
+
+ {statsLoading ? (
+
+ Loading survey statistics...
+
+ ) : statsError ? (
+
+ {getUserFriendlyErrorMessage(statsError)}
+
+ ) : stats ? (
+
+
+
+
+ Total responses
+
+
+ {stats.totalResponses}
+
+
+
+
+ Completion rate
+
+
+ {stats.completionRate}%
+
+
+
+
+
+ Completed
+
+
+ {stats.completedResponses}
+
+
+
+
+ Skipped
+
+
+ {stats.skippedResponses}
+
+
+
+
+ Average completion time
+
+
+ {formatDuration(stats.averageCompletionTime)}
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+ Survey metadata
+
+
+ Lifecycle details and trigger context.
+
+
+
+
+
+
+
+ Trigger
+
+
+ {triggerLabel}
+
+
+
+
+ Status
+
+
+ {status.label}
+
+
+
+
+ Time to complete
+
+
+ {formatDuration(currentSurvey.timeToComplete)}
+
+
+
+
+ Expires
+
+
+ {formatDateTime(currentSurvey.expiresAt)}
+
+
+
+
+ Created
+
+
+ {formatDateTime(currentSurvey.createdAt)}
+
+
+
+
+ Updated
+
+
+ {formatDateTime(currentSurvey.updatedAt)}
+
+
+
+
+
+
+
+
+
+
+
+ Responses
+
+
+ Review individual submissions and export the collected data.
+
+
+
+
+ {filteredResponses.length} matched
+
+
+
+
+ Export
+
+
+
+
+ Export CSV
+
+
+ Export PDF
+
+
+
+
+
+
+
+
+
+
+
+ setDeleteDialogOpen(false)}
+ title="Delete survey"
+ subtitle="This action permanently removes the survey and its management entry."
+ size="md"
+ primaryAction={{
+ label: 'Delete survey',
+ onClick: handleDeleteSurvey,
+ variant: 'danger',
+ disabled: isDeleting,
+ loading: isDeleting,
+ }}
+ secondaryAction={{
+ label: 'Cancel',
+ onClick: () => setDeleteDialogOpen(false),
+ variant: 'outlined',
+ disabled: isDeleting,
+ }}
+ >
+
+ Are you sure you want to delete{' '}
+
+ {currentSurvey.title}
+
+ ? This cannot be undone.
+
+
+
+ setSelectedResponse(null)}
+ response={selectedResponse}
+ survey={currentSurvey}
+ />
+
+
+ );
+};
+
+export default SurveyDetailsPage;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx
new file mode 100644
index 0000000000..47c92790f0
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx
@@ -0,0 +1,443 @@
+'use client';
+
+import React, { useEffect, useMemo, useState } from 'react';
+import { AqPlus, AqTrash01 } from '@airqo/icons-react';
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ Input,
+ TextInput,
+ Select,
+ toast,
+} from '@/shared/components/ui';
+import { surveyService } from '@/shared/services';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import type { Survey } from '@/shared/types/api';
+import {
+ SURVEY_QUESTION_TYPES,
+ createQuestionDraft,
+ formatQuestionTypeLabel,
+ serializeQuestionDraft,
+ type SurveyQuestionDraft,
+} from '../utils';
+
+interface SurveyEditorDialogProps {
+ isOpen: boolean;
+ survey: Survey | null;
+ onClose: () => void;
+ onSaved: (updatedSurvey: Survey) => Promise | void;
+}
+
+const buildInitialQuestions = (
+ survey: Survey | null
+): SurveyQuestionDraft[] => {
+ if (!survey?.questions?.length) {
+ return [createQuestionDraft()];
+ }
+
+ return survey.questions.map(question => createQuestionDraft(question));
+};
+
+const SurveyEditorDialog: React.FC = ({
+ isOpen,
+ survey,
+ onClose,
+ onSaved,
+}) => {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [isActive, setIsActive] = useState(true);
+ const [timeToComplete, setTimeToComplete] = useState('0');
+ const [questions, setQuestions] = useState([
+ createQuestionDraft(),
+ ]);
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen || !survey) {
+ return;
+ }
+
+ setTitle(survey.title || '');
+ setDescription(survey.description || '');
+ setIsActive(survey.isActive ?? true);
+ setTimeToComplete(String(survey.timeToComplete ?? 0));
+ setQuestions(buildInitialQuestions(survey));
+ }, [isOpen, survey]);
+
+ const totalQuestions = useMemo(() => questions.length, [questions.length]);
+
+ const handleAddQuestion = () => {
+ setQuestions(previous => [...previous, createQuestionDraft()]);
+ };
+
+ const handleRemoveQuestion = (index: number) => {
+ setQuestions(previous => {
+ if (previous.length <= 1) {
+ return [createQuestionDraft()];
+ }
+
+ return previous.filter((_, currentIndex) => currentIndex !== index);
+ });
+ };
+
+ const handleQuestionChange = (
+ index: number,
+ field: keyof SurveyQuestionDraft,
+ value: string | boolean
+ ) => {
+ setQuestions(previous =>
+ previous.map((question, currentIndex) => {
+ if (currentIndex !== index) {
+ return question;
+ }
+
+ return {
+ ...question,
+ [field]: value,
+ };
+ })
+ );
+ };
+
+ const handleSubmit = async () => {
+ const normalizedTitle = title.trim();
+ const normalizedDescription = description.trim();
+ const normalizedTimeToComplete = Number(timeToComplete);
+
+ if (!survey) {
+ toast.error('Survey data is not available');
+ return;
+ }
+
+ if (!normalizedTitle) {
+ toast.error('Survey title is required');
+ return;
+ }
+
+ if (
+ !Number.isFinite(normalizedTimeToComplete) ||
+ normalizedTimeToComplete < 0
+ ) {
+ toast.error('Enter a valid time to complete in seconds');
+ return;
+ }
+
+ const validationError = questions.find((question, index) => {
+ if (!question.question.trim()) {
+ toast.error(`Question ${index + 1} needs text`);
+ return true;
+ }
+
+ if (question.type === 'multipleChoice') {
+ const optionCount = question.optionsText
+ .split(/\r?\n/)
+ .map(option => option.trim())
+ .filter(Boolean).length;
+
+ if (optionCount < 2) {
+ toast.error(
+ `Question ${index + 1} needs at least two options for multiple choice`
+ );
+ return true;
+ }
+ }
+
+ if (question.type === 'rating') {
+ const minValue = Number(question.minValue);
+ const maxValue = Number(question.maxValue);
+
+ if (
+ !Number.isFinite(minValue) ||
+ !Number.isFinite(maxValue) ||
+ minValue > maxValue
+ ) {
+ toast.error(`Question ${index + 1} needs a valid rating range`);
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ if (validationError) {
+ return;
+ }
+
+ setIsSaving(true);
+
+ try {
+ const updatedSurvey = await surveyService.updateSurvey(survey._id, {
+ title: normalizedTitle,
+ description: normalizedDescription,
+ isActive,
+ timeToComplete: normalizedTimeToComplete,
+ questions: questions.map(question => serializeQuestionDraft(question)),
+ });
+
+ toast.success('Survey updated successfully');
+ await onSaved(updatedSurvey);
+ onClose();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ setTitle(e.target.value)}
+ placeholder="Air quality awareness survey"
+ required
+ />
+
+ setDescription(e.target.value)}
+ placeholder="Short description that explains the survey purpose."
+ rows={4}
+ />
+
+
+
+
setTimeToComplete(String(e.target.value))}
+ description="Used to estimate completion effort."
+ />
+
+
+
+
setIsActive(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+
+ Active survey
+
+
+ Active surveys remain visible in the system list and can keep
+ collecting responses.
+
+
+
+
+
+
+
+
+
+
Questions
+
+ {totalQuestions} question
+ {totalQuestions === 1 ? '' : 's'} in this survey. Use one option
+ per line for multiple choice questions.
+
+
+
+
+ Add question
+
+
+
+
+ {questions.map((question, index) => {
+ const isMultipleChoice = question.type === 'multipleChoice';
+ const isRating = question.type === 'rating';
+
+ return (
+
+
+
+
+ Question {index + 1}
+
+
+ {formatQuestionTypeLabel(question.type)}
+
+
+
+
handleRemoveQuestion(index)}
+ className="text-red-600 hover:text-red-700"
+ >
+ Remove
+
+
+
+
+
+
+ handleQuestionChange(
+ index,
+ 'question',
+ e.target.value
+ )
+ }
+ placeholder="What was your primary activity when you received this survey?"
+ rows={3}
+ required
+ />
+
+
+
+ handleQuestionChange(
+ index,
+ 'type',
+ String(event.target.value || 'text')
+ )
+ }
+ required
+ >
+ {SURVEY_QUESTION_TYPES.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ handleQuestionChange(
+ index,
+ 'isRequired',
+ Boolean(checked)
+ )
+ }
+ className="mt-0.5"
+ />
+
+
+ Required response
+
+
+ Required questions must be answered before the
+ survey can be completed.
+
+
+
+
+
+ {isRating && (
+ <>
+
+ handleQuestionChange(
+ index,
+ 'minValue',
+ e.target.value
+ )
+ }
+ description="Lowest score allowed in this question."
+ />
+
+ handleQuestionChange(
+ index,
+ 'maxValue',
+ e.target.value
+ )
+ }
+ description="Highest score allowed in this question."
+ />
+ >
+ )}
+
+ {isMultipleChoice && (
+
+
+ handleQuestionChange(
+ index,
+ 'optionsText',
+ e.target.value
+ )
+ }
+ placeholder={`Option one\nOption two\nOption three`}
+ rows={4}
+ />
+
+ Enter one option per line.
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+export default SurveyEditorDialog;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx
new file mode 100644
index 0000000000..2fb81ae037
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx
@@ -0,0 +1,788 @@
+'use client';
+
+import React, { useEffect, useMemo, useState } from 'react';
+import { AqArrowLeft, AqPlus, AqTrash01 } from '@airqo/icons-react';
+import type {
+ CreateSurveyRequest,
+ Survey,
+ UpdateSurveyRequest,
+} from '@/shared/types/api';
+import {
+ Button,
+ Card,
+ Checkbox,
+ Input,
+ PageHeading,
+ Select,
+ TextInput,
+ toast,
+} from '@/shared/components/ui';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import {
+ createQuestionDraft,
+ createTriggerDraft,
+ formatDateTime,
+ formatDuration,
+ formatQuestionTypeLabel,
+ getSurveyTriggerTypeLabel,
+ SURVEY_QUESTION_TYPES,
+ SURVEY_TRIGGER_TYPES,
+ serializeQuestionDraft,
+ serializeTriggerDraft,
+ type SurveyQuestionDraft,
+ type SurveyTriggerDraft,
+} from '../utils';
+
+type SurveyPayload = CreateSurveyRequest | UpdateSurveyRequest;
+
+interface SurveyFormProps {
+ mode: 'create' | 'edit';
+ initialSurvey?: Survey | null;
+ onCancel: () => void;
+ onSubmit: (payload: SurveyPayload) => Promise;
+ onSuccess: (survey: Survey) => Promise | void;
+}
+
+const formatDateTimeLocal = (value?: string | null): string => {
+ if (!value) {
+ return '';
+ }
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return '';
+ }
+
+ const offset = parsed.getTimezoneOffset() * 60000;
+ const localDate = new Date(parsed.getTime() - offset);
+ return localDate.toISOString().slice(0, 16);
+};
+
+const buildQuestionErrors = (question: SurveyQuestionDraft): string[] => {
+ const errors: string[] = [];
+
+ if (!question.question.trim()) {
+ errors.push('Question text is required');
+ }
+
+ if (question.type === 'multipleChoice') {
+ const optionCount = question.optionsText
+ .split(/\r?\n/)
+ .map(option => option.trim())
+ .filter(Boolean).length;
+
+ if (optionCount < 2) {
+ errors.push('Add at least two options for multiple choice');
+ }
+ }
+
+ if (question.type === 'rating' || question.type === 'scale') {
+ const minValue = Number(question.minValue);
+ const maxValue = Number(question.maxValue);
+
+ if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
+ errors.push('Provide a valid rating range');
+ } else if (minValue > maxValue) {
+ errors.push('Minimum value must be less than or equal to maximum');
+ }
+ }
+
+ return errors;
+};
+
+const SurveyForm: React.FC = ({
+ mode,
+ initialSurvey,
+ onCancel,
+ onSubmit,
+ onSuccess,
+}) => {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [isActive, setIsActive] = useState(true);
+ const [timeToComplete, setTimeToComplete] = useState('');
+ const [expiresAt, setExpiresAt] = useState('');
+ const [triggerDraft, setTriggerDraft] =
+ useState(createTriggerDraft());
+ const [questions, setQuestions] = useState([
+ createQuestionDraft(),
+ ]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState>({});
+
+ useEffect(() => {
+ if (mode === 'edit' && initialSurvey) {
+ setTitle(initialSurvey.title || '');
+ setDescription(initialSurvey.description || '');
+ setIsActive(initialSurvey.isActive ?? true);
+ setTimeToComplete(String(initialSurvey.timeToComplete ?? ''));
+ setExpiresAt(formatDateTimeLocal(initialSurvey.expiresAt));
+ setTriggerDraft(createTriggerDraft(initialSurvey.trigger || undefined));
+ setQuestions(
+ initialSurvey.questions.length > 0
+ ? initialSurvey.questions.map(question =>
+ createQuestionDraft(question)
+ )
+ : [createQuestionDraft()]
+ );
+ return;
+ }
+
+ if (mode === 'create') {
+ setTitle('');
+ setDescription('');
+ setIsActive(true);
+ setTimeToComplete('180');
+ setExpiresAt('');
+ setTriggerDraft(createTriggerDraft());
+ setQuestions([createQuestionDraft()]);
+ }
+ }, [initialSurvey, mode]);
+
+ const questionSummary = useMemo(() => {
+ const total = questions.length;
+ const required = questions.filter(question => question.isRequired).length;
+ return { total, required };
+ }, [questions]);
+
+ const handleAddQuestion = () => {
+ setQuestions(previous => [...previous, createQuestionDraft()]);
+ };
+
+ const handleRemoveQuestion = (index: number) => {
+ setQuestions(previous => {
+ if (previous.length <= 1) {
+ return [createQuestionDraft()];
+ }
+
+ return previous.filter((_, currentIndex) => currentIndex !== index);
+ });
+ };
+
+ const handleQuestionChange = (
+ index: number,
+ field: keyof SurveyQuestionDraft,
+ value: string | boolean
+ ) => {
+ setQuestions(previous =>
+ previous.map((question, currentIndex) =>
+ currentIndex === index ? { ...question, [field]: value } : question
+ )
+ );
+ };
+
+ const handleSubmit = async () => {
+ const normalizedTitle = title.trim();
+ const normalizedDescription = description.trim();
+ const normalizedTimeToComplete = Number(timeToComplete);
+ const normalizedExpiresAt = expiresAt
+ ? new Date(expiresAt).toISOString()
+ : undefined;
+ const serializedQuestions = questions.map(question =>
+ serializeQuestionDraft(question)
+ );
+ const serializedTrigger = serializeTriggerDraft(triggerDraft);
+
+ const nextErrors: Record = {};
+
+ if (!normalizedTitle) {
+ nextErrors.title = ['Survey title is required'];
+ }
+
+ if (
+ !Number.isFinite(normalizedTimeToComplete) ||
+ normalizedTimeToComplete < 0
+ ) {
+ nextErrors.timeToComplete = ['Enter a valid completion time in seconds'];
+ }
+
+ if (triggerDraft.type === 'postExposure') {
+ const threshold = Number(triggerDraft.threshold);
+ const duration = Number(triggerDraft.duration);
+
+ if (!Number.isFinite(threshold) || threshold < 0) {
+ nextErrors.trigger = [
+ ...(nextErrors.trigger || []),
+ 'Threshold must be a valid number',
+ ];
+ }
+
+ if (!Number.isFinite(duration) || duration <= 0) {
+ nextErrors.trigger = [
+ ...(nextErrors.trigger || []),
+ 'Duration must be greater than zero',
+ ];
+ }
+ }
+
+ questions.forEach((question, index) => {
+ const errors = buildQuestionErrors(question);
+ if (errors.length > 0) {
+ nextErrors[`question-${index}`] = errors;
+ }
+ });
+
+ setFieldErrors(nextErrors);
+
+ if (Object.keys(nextErrors).length > 0) {
+ const firstMessage =
+ nextErrors.title?.[0] ||
+ nextErrors.timeToComplete?.[0] ||
+ nextErrors.trigger?.[0] ||
+ nextErrors['question-0']?.[0] ||
+ 'Please fix the highlighted errors';
+ toast.error(firstMessage);
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const payload: SurveyPayload = {
+ title: normalizedTitle,
+ description: normalizedDescription,
+ isActive,
+ timeToComplete: normalizedTimeToComplete,
+ expiresAt: normalizedExpiresAt,
+ trigger: serializedTrigger,
+ questions: serializedQuestions,
+ };
+
+ const savedSurvey = await onSubmit(payload);
+ toast.success(
+ mode === 'edit'
+ ? 'Survey updated successfully'
+ : 'Survey created successfully'
+ );
+ await onSuccess(savedSurvey);
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const titleError = fieldErrors.title?.[0];
+ const timeToCompleteError = fieldErrors.timeToComplete?.[0];
+ const triggerError = fieldErrors.trigger?.[0];
+
+ return (
+
+
+
+ Back
+
+
+
+
+
+ Cancel
+
+
+ {mode === 'edit' ? 'Save changes' : 'Create survey'}
+
+
+ }
+ >
+
+
+ {questionSummary.total} questions
+
+
+ {questionSummary.required} required
+
+
+ {isActive ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+
+
+
+ Survey details
+
+
+ Title, description, lifecycle, and timing.
+
+
+
+
setTitle(e.target.value)}
+ placeholder="Air Quality Exposure Assessment"
+ required
+ error={titleError}
+ />
+
+
setDescription(e.target.value)}
+ placeholder="Help us understand how air quality affects your daily activities and health."
+ rows={4}
+ />
+
+
+ setTimeToComplete(String(e.target.value))}
+ description="Estimate the time respondents should need to finish."
+ error={timeToCompleteError}
+ />
+
+ setExpiresAt(String(e.target.value))}
+ description="Optional end date for the survey."
+ />
+
+
+
+
+
setIsActive(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+
+ Survey active
+
+
+ Active surveys can be shown to respondents immediately.
+
+
+
+
+
+
+
+
+
+
+
+
+ Trigger
+
+
+ Choose when the survey should be delivered.
+
+
+
+
+
+
+ setTriggerDraft(previous => ({
+ ...previous,
+ type: String(event.target.value || 'manual'),
+ }))
+ }
+ required
+ >
+ {SURVEY_TRIGGER_TYPES.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ Current trigger
+
+
+ {getSurveyTriggerTypeLabel(triggerDraft.type)}
+
+ {triggerDraft.type === 'postExposure' ? (
+
+ Deliver the survey when air quality rises above the
+ threshold for the configured duration.
+
+ ) : (
+
+ Manual trigger keeps the survey controlled by the admin.
+
+ )}
+
+
+
+ {triggerDraft.type === 'postExposure' && (
+
+
+ setTriggerDraft(previous => ({
+ ...previous,
+ threshold: String(e.target.value),
+ }))
+ }
+ description="Example: 50"
+ error={triggerError}
+ />
+
+ setTriggerDraft(previous => ({
+ ...previous,
+ duration: String(e.target.value),
+ }))
+ }
+ description="How long the threshold must stay elevated."
+ error={triggerError}
+ />
+
+ )}
+
+
+
+
+
+
+
+
+ Questions
+
+
+ Build the survey structure. One option per line for multiple
+ choice questions.
+
+
+
+ Add question
+
+
+
+
+ {questions.map((question, index) => {
+ const isMultipleChoice = question.type === 'multipleChoice';
+ const isRating = question.type === 'rating';
+ const isScale = question.type === 'scale';
+ const isYesNo = question.type === 'yesNo';
+ const questionErrors = fieldErrors[`question-${index}`] || [];
+
+ return (
+
+
+
+
+ Question {index + 1}
+
+
+ {formatQuestionTypeLabel(question.type)}
+
+
+
handleRemoveQuestion(index)}
+ >
+ Remove
+
+
+
+
+
+
+ handleQuestionChange(
+ index,
+ 'question',
+ e.target.value
+ )
+ }
+ placeholder="What activity were you doing when you received this survey?"
+ rows={3}
+ required
+ />
+
+
+
+ handleQuestionChange(
+ index,
+ 'type',
+ String(event.target.value || 'text')
+ )
+ }
+ required
+ >
+ {SURVEY_QUESTION_TYPES.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ handleQuestionChange(
+ index,
+ 'isRequired',
+ Boolean(checked)
+ )
+ }
+ className="mt-0.5"
+ />
+
+
+ Required response
+
+
+ Required questions block completion until
+ answered.
+
+
+
+
+
+ {isRating || isScale ? (
+ <>
+
+ handleQuestionChange(
+ index,
+ 'minValue',
+ e.target.value
+ )
+ }
+ description="Lowest allowed value."
+ />
+
+ handleQuestionChange(
+ index,
+ 'maxValue',
+ e.target.value
+ )
+ }
+ description="Highest allowed value."
+ />
+ >
+ ) : null}
+
+ {question.type === 'text' && (
+
+
+ handleQuestionChange(
+ index,
+ 'placeholder',
+ e.target.value
+ )
+ }
+ placeholder="Enter your answer here..."
+ description="Shown inside the response field."
+ />
+
+ )}
+
+ {isMultipleChoice && (
+
+
+ handleQuestionChange(
+ index,
+ 'optionsText',
+ e.target.value
+ )
+ }
+ placeholder={`Option one\nOption two\nOption three`}
+ rows={4}
+ />
+
+ Enter one option per line.
+
+
+ )}
+
+ {isYesNo && (
+
+ This question will render as a fixed Yes / No
+ choice.
+
+ )}
+
+
+ {questionErrors.length > 0 && (
+
+
+ {questionErrors.map(error => (
+ {error}
+ ))}
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+ Cancel
+
+
+ {mode === 'edit' ? 'Save changes' : 'Create survey'}
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+ What admins will see in the survey list and details view.
+
+
+
+
+
+ Title
+
+
+ {title || 'Untitled survey'}
+
+
+ {description || 'No description provided yet.'}
+
+
+
+
+
+
+ Estimated time
+
+
+ {formatDuration(Number(timeToComplete) || 0)}
+
+
+
+
+ Trigger
+
+
+ {getSurveyTriggerTypeLabel(triggerDraft.type)}
+
+
+
+
+ Status
+
+
+ {isActive ? 'Active' : 'Inactive'}
+
+
+
+
+ Expires
+
+
+ {expiresAt
+ ? formatDateTime(new Date(expiresAt).toISOString())
+ : 'Not set'}
+
+
+
+
+ {mode === 'edit' && initialSurvey && (
+
+
+ Created {formatDateTime(initialSurvey.createdAt)} and last
+ updated {formatDateTime(initialSurvey.updatedAt)}.
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default SurveyForm;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx
new file mode 100644
index 0000000000..6b9168cbc0
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx
@@ -0,0 +1,204 @@
+'use client';
+
+import React, { useMemo } from 'react';
+import { Card, Dialog } from '@/shared/components/ui';
+import type { Survey, SurveyResponseItem } from '@/shared/types/api';
+import {
+ formatDateTime,
+ formatDuration,
+ formatQuestionTypeLabel,
+ formatResponseValue,
+} from '../utils';
+
+interface SurveyResponseDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ response: SurveyResponseItem | null;
+ survey: Survey | null;
+}
+
+const DetailChip: React.FC<{
+ label: string;
+ value: React.ReactNode;
+}> = ({ label, value }) => (
+
+
+ {label}
+
+
{value}
+
+);
+
+const SurveyResponseDialog: React.FC = ({
+ isOpen,
+ onClose,
+ response,
+ survey,
+}) => {
+ const questionLookup = useMemo(() => {
+ return new Map(
+ survey?.questions?.map(question => [question.id, question]) || []
+ );
+ }, [survey?.questions]);
+
+ const statusLabel = response?.status
+ ? formatQuestionTypeLabel(response.status)
+ : 'Unknown';
+
+ return (
+
+ {response ? (
+
+
+
+ {statusLabel}
+
+ }
+ />
+
+ {response.user?.email || response.userId || 'Unknown'}
+
+ {response.user
+ ? `${response.user.firstName || ''} ${response.user.lastName || ''}`.trim() ||
+ 'No user profile attached'
+ : response.isGuest
+ ? 'Guest submission'
+ : 'No user profile attached'}
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {response.deviceId || 'Not tracked'}
+
+ }
+ />
+
+ {response.userId || 'Not tracked'}
+
+ }
+ />
+
+
+
+
+
+
+
+
+ Answers
+
+
+ Answers are shown in the order they were submitted.
+
+
+
+
+ {response.answers.length > 0 ? (
+ response.answers.map(answer => {
+ const question = questionLookup.get(answer.questionId);
+
+ return (
+
+
+
+
+ {question?.question ||
+ `Question ${answer.questionId}`}
+
+
+
+ {question
+ ? formatQuestionTypeLabel(question.type)
+ : 'Unknown type'}
+
+ {answer.questionId}
+
+
+
+
+
+ Answer
+
+
+ {formatResponseValue(answer.answer)}
+
+
+ Answered at {formatDateTime(answer.answeredAt)}
+
+
+
+
+ );
+ })
+ ) : (
+
+
+ No answers were captured for this submission.
+
+
+ )}
+
+
+
+ ) : null}
+
+ );
+};
+
+export default SurveyResponseDialog;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx
new file mode 100644
index 0000000000..9ccb3b785e
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { useSWRConfig } from 'swr';
+import { PermissionGuard } from '@/shared/components';
+import type {
+ CreateSurveyRequest,
+ Survey,
+ UpdateSurveyRequest,
+} from '@/shared/types/api';
+import { surveyService } from '@/shared/services';
+import SurveyForm from '../components/SurveyForm';
+
+const SurveyCreatePage: React.FC = () => {
+ const router = useRouter();
+ const { mutate } = useSWRConfig();
+
+ const handleCancel = useCallback(() => {
+ router.push('/system/surveys');
+ }, [router]);
+
+ const handleSubmit = useCallback(
+ async (payload: CreateSurveyRequest | UpdateSurveyRequest) => {
+ return surveyService.createSurvey(payload as CreateSurveyRequest);
+ },
+ []
+ );
+
+ const handleSuccess = useCallback(
+ async (survey: Survey) => {
+ await mutate('system/surveys');
+
+ if (survey?._id) {
+ router.replace(`/system/surveys/${survey._id}`);
+ return;
+ }
+
+ router.replace('/system/surveys');
+ },
+ [mutate, router]
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default SurveyCreatePage;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/page.tsx
new file mode 100644
index 0000000000..fea4bc5e51
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/page.tsx
@@ -0,0 +1,294 @@
+'use client';
+
+import React, { useMemo, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import useSWR from 'swr';
+import { PermissionGuard } from '@/shared/components';
+import {
+ Button,
+ Card,
+ EmptyState,
+ LoadingState,
+ PageHeading,
+ toast,
+} from '@/shared/components/ui';
+import { AqEye, AqPlus, AqRefreshCw05 } from '@airqo/icons-react';
+import { surveyService } from '@/shared/services';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { refreshWithToast } from '@/shared/utils/refreshWithToast';
+import type { Survey } from '@/shared/types/api';
+import {
+ formatDateTime,
+ formatDuration,
+ getSurveyTriggerLabel,
+ formatSurveyStatus,
+} from './utils';
+
+const SurveyListPage: React.FC = () => {
+ const router = useRouter();
+
+ const {
+ data: surveys = [],
+ error,
+ isLoading,
+ mutate,
+ isValidating,
+ } = useSWR('system/surveys', () => surveyService.getActiveSurveys(), {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ });
+
+ const summary = useMemo(() => {
+ const totalQuestions = surveys.reduce(
+ (sum, survey) => sum + (survey.questionCount ?? survey.questions.length),
+ 0
+ );
+ const requiredQuestions = surveys.reduce(
+ (sum, survey) =>
+ sum +
+ (survey.requiredQuestionCount ??
+ survey.questions.filter(question => question.isRequired).length),
+ 0
+ );
+ const averageCompletionTime =
+ surveys.length > 0
+ ? Math.round(
+ surveys.reduce((sum, survey) => sum + survey.timeToComplete, 0) /
+ surveys.length
+ )
+ : 0;
+ const activeSurveys = surveys.filter(survey => survey.isActive !== false);
+
+ return {
+ activeSurveys: activeSurveys.length,
+ totalQuestions,
+ requiredQuestions,
+ averageCompletionTime,
+ };
+ }, [surveys]);
+
+ const handleOpenSurvey = useCallback(
+ (surveyId: string) => {
+ router.push(`/system/surveys/${surveyId}`);
+ },
+ [router]
+ );
+
+ const handleCreateSurvey = useCallback(() => {
+ router.push('/system/surveys/new');
+ }, [router]);
+
+ const handleRefresh = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => mutate(),
+ 'Survey list refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ }
+ }, [mutate]);
+
+ const renderSurveyCard = useCallback(
+ (survey: Survey) => {
+ const status = formatSurveyStatus(
+ survey.isActive,
+ survey.surveyStatus,
+ survey.isExpired
+ );
+
+ const questionCount = survey.questionCount ?? survey.questions.length;
+ const requiredCount =
+ survey.requiredQuestionCount ??
+ survey.questions.filter(question => question.isRequired).length;
+
+ return (
+
+
+
+
+
+ {status.label}
+
+
+ {questionCount} question{questionCount === 1 ? '' : 's'}
+
+
+ {requiredCount} required
+
+
+
+
+
+ {survey.title}
+
+
+ {survey.description || 'No description provided.'}
+
+
+
+
+
+
+
+
+ Estimated time
+
+
+ {formatDuration(survey.timeToComplete)}
+
+
+
+
+ Updated
+
+
+ {formatDateTime(survey.updatedAt)}
+
+
+
+
+ Created
+
+
+ {formatDateTime(survey.createdAt)}
+
+
+
+
+ Expires
+
+
+ {formatDateTime(survey.expiresAt)}
+
+
+
+
+
+
+ Trigger: {getSurveyTriggerLabel(survey.trigger)}
+
+
+
handleOpenSurvey(survey._id)}
+ >
+ View details
+
+
+
+ );
+ },
+ [handleOpenSurvey]
+ );
+
+ return (
+
+
+
+
+ Refresh
+
+
+ Create survey
+
+
+ }
+ />
+
+
+
+ Active surveys
+
+ {summary.activeSurveys}
+
+
+ Available to the system team right now.
+
+
+
+
+ Total questions
+
+ {summary.totalQuestions}
+
+
+ Across all active surveys.
+
+
+
+
+ Required questions
+
+ {summary.requiredQuestions}
+
+
+ Must be answered before completion.
+
+
+
+
+
+ Avg. completion time
+
+
+ {formatDuration(summary.averageCompletionTime)}
+
+
+ Average across active surveys.
+
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+
+ {getUserFriendlyErrorMessage(error)}
+
+
+ ) : surveys.length === 0 ? (
+
+ ) : (
+
+ {surveys.map(renderSurveyCard)}
+
+ )}
+
+
+ );
+};
+
+export default SurveyListPage;
diff --git a/src/platform/src/app/(dashboard)/system/surveys/utils.ts b/src/platform/src/app/(dashboard)/system/surveys/utils.ts
new file mode 100644
index 0000000000..547bafd091
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/surveys/utils.ts
@@ -0,0 +1,287 @@
+import type { SurveyQuestion, SurveyTrigger } from '@/shared/types/api';
+
+export const SURVEY_QUESTION_TYPES = [
+ { value: 'text', label: 'Text' },
+ { value: 'multipleChoice', label: 'Multiple choice' },
+ { value: 'rating', label: 'Rating' },
+ { value: 'yesNo', label: 'Yes / No' },
+ { value: 'scale', label: 'Scale' },
+] as const;
+
+export const SURVEY_TRIGGER_TYPES = [
+ { value: 'manual', label: 'Manual' },
+ { value: 'postExposure', label: 'Post-exposure' },
+ { value: 'scheduled', label: 'Scheduled' },
+] as const;
+
+export type SurveyQuestionDraft = {
+ id: string;
+ question: string;
+ type: string;
+ optionsText: string;
+ placeholder: string;
+ isRequired: boolean;
+ minValue: string;
+ maxValue: string;
+};
+
+export type SurveyTriggerDraft = {
+ type: string;
+ threshold: string;
+ duration: string;
+};
+
+export const createQuestionDraft = (
+ question?: SurveyQuestion
+): SurveyQuestionDraft => ({
+ id:
+ question?.id ||
+ `question_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+ question: question?.question || '',
+ type: question?.type || 'text',
+ optionsText: (question?.options || []).join('\n'),
+ placeholder: question?.placeholder || '',
+ isRequired: question?.isRequired ?? true,
+ minValue: question?.minValue != null ? String(question.minValue) : '',
+ maxValue: question?.maxValue != null ? String(question.maxValue) : '',
+});
+
+export const createTriggerDraft = (
+ trigger?: SurveyTrigger | null
+): SurveyTriggerDraft => ({
+ type: trigger?.type || 'manual',
+ threshold:
+ typeof trigger?.conditions?.threshold === 'number'
+ ? String(trigger.conditions.threshold)
+ : '',
+ duration:
+ typeof trigger?.conditions?.duration === 'number'
+ ? String(trigger.conditions.duration)
+ : '',
+});
+
+export const serializeQuestionDraft = (
+ draft: SurveyQuestionDraft
+): SurveyQuestion => {
+ const typedOptions =
+ draft.type === 'multipleChoice'
+ ? draft.optionsText
+ .split(/\r?\n/)
+ .map(option => option.trim())
+ .filter(Boolean)
+ : draft.type === 'yesNo'
+ ? ['Yes', 'No']
+ : [];
+
+ const minValue =
+ (draft.type === 'rating' || draft.type === 'scale') &&
+ draft.minValue.trim() !== ''
+ ? Number(draft.minValue)
+ : undefined;
+ const maxValue =
+ (draft.type === 'rating' || draft.type === 'scale') &&
+ draft.maxValue.trim() !== ''
+ ? Number(draft.maxValue)
+ : undefined;
+
+ return {
+ id: draft.id.trim() || `question_${Date.now()}`,
+ question: draft.question.trim(),
+ type: draft.type.trim() || 'text',
+ options: typedOptions,
+ isRequired: draft.isRequired,
+ placeholder: draft.placeholder.trim() || undefined,
+ ...(Number.isFinite(minValue) ? { minValue } : {}),
+ ...(Number.isFinite(maxValue) ? { maxValue } : {}),
+ };
+};
+
+export const serializeTriggerDraft = (
+ draft: SurveyTriggerDraft
+): SurveyTrigger => {
+ const threshold =
+ draft.threshold.trim() !== '' ? Number(draft.threshold) : undefined;
+ const duration =
+ draft.duration.trim() !== '' ? Number(draft.duration) : undefined;
+
+ const conditions: Record = {};
+ if (Number.isFinite(threshold)) {
+ conditions.threshold = Number(threshold);
+ }
+ if (Number.isFinite(duration)) {
+ conditions.duration = Number(duration);
+ }
+
+ return {
+ type: draft.type.trim() || 'manual',
+ ...(Object.keys(conditions).length > 0 ? { conditions } : {}),
+ };
+};
+
+export const formatDateTime = (value?: string | null): string => {
+ if (!value) {
+ return 'Not available';
+ }
+
+ const parsedDate = new Date(value);
+ if (Number.isNaN(parsedDate.getTime())) {
+ return value;
+ }
+
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(parsedDate);
+};
+
+export const formatDuration = (seconds?: number | null): string => {
+ if (seconds == null || Number.isNaN(Number(seconds))) {
+ return 'Not available';
+ }
+
+ const totalSeconds = Math.max(0, Math.round(Number(seconds)));
+ if (totalSeconds < 60) {
+ return `${totalSeconds}s`;
+ }
+
+ const minutes = Math.floor(totalSeconds / 60);
+ const remainingSeconds = totalSeconds % 60;
+
+ if (minutes < 60) {
+ return remainingSeconds > 0
+ ? `${minutes}m ${remainingSeconds}s`
+ : `${minutes}m`;
+ }
+
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
+};
+
+export const formatQuestionTypeLabel = (type: string): string => {
+ switch (type) {
+ case 'text':
+ return 'Text';
+ case 'multipleChoice':
+ return 'Multiple choice';
+ case 'rating':
+ return 'Rating';
+ case 'yesNo':
+ return 'Yes / No';
+ case 'scale':
+ return 'Scale';
+ default:
+ return type
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/[_-]/g, ' ')
+ .replace(/\b\w/g, character => character.toUpperCase());
+ }
+};
+
+export const formatResponseValue = (value: unknown): string => {
+ if (Array.isArray(value)) {
+ return value.map(item => String(item)).join(', ');
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return 'Not answered';
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No';
+ }
+
+ return String(value);
+};
+
+export const getTopAnswerEntries = (
+ distribution?: Record,
+ limit = 3
+): Array<[string, number]> => {
+ if (!distribution) {
+ return [];
+ }
+
+ return Object.entries(distribution)
+ .sort((left, right) => right[1] - left[1])
+ .slice(0, limit);
+};
+
+export const getTotalAnswerCount = (
+ distribution?: Record
+): number => {
+ if (!distribution) {
+ return 0;
+ }
+
+ return Object.values(distribution).reduce(
+ (sum, value) => sum + Number(value || 0),
+ 0
+ );
+};
+
+export const formatSurveyStatus = (
+ isActive?: boolean,
+ surveyStatus?: string,
+ isExpired?: boolean
+): { label: string; tone: string } => {
+ if (isExpired) {
+ return {
+ label: 'Expired',
+ tone: 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300',
+ };
+ }
+
+ if (isActive === false) {
+ return {
+ label: surveyStatus ? formatQuestionTypeLabel(surveyStatus) : 'Inactive',
+ tone: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
+ };
+ }
+
+ if (surveyStatus) {
+ return {
+ label: formatQuestionTypeLabel(surveyStatus),
+ tone: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300',
+ };
+ }
+
+ return {
+ label: isActive ? 'Active' : 'Inactive',
+ tone: isActive
+ ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300'
+ : 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300',
+ };
+};
+
+export const getQuestionDistribution = (
+ distribution: Record> | undefined,
+ questionId: string
+): Record | undefined => {
+ return distribution?.[questionId];
+};
+
+export const getSurveyTriggerLabel = (
+ trigger?: SurveyTrigger | null
+): string => {
+ if (!trigger) {
+ return 'Manual trigger';
+ }
+
+ const triggerType = trigger.type;
+ if (!triggerType) {
+ return 'Manual trigger';
+ }
+
+ const label = getSurveyTriggerTypeLabel(String(triggerType));
+ return label === 'Manual' ? 'Manual trigger' : label;
+};
+
+export const getSurveyTriggerTypeLabel = (type: string): string => {
+ const match = SURVEY_TRIGGER_TYPES.find(option => option.value === type);
+ return match?.label || formatQuestionTypeLabel(type);
+};
diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
index 24bb131b88..4f6e7fdbc5 100644
--- a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
@@ -27,6 +27,7 @@ import {
} from '@airqo/icons-react';
import { formatWithPattern } from '@/shared/utils/dateUtils';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { refreshWithToast } from '@/shared/utils/refreshWithToast';
const UserStatisticsDetailsPage: React.FC = () => {
const params = useParams();
@@ -116,9 +117,15 @@ const UserStatisticsDetailsPage: React.FC = () => {
router.push('/system/user-statistics');
}, [router]);
- const handleRefresh = useCallback(() => {
- mutateUser();
- mutateRoles();
+ const handleRefresh = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => Promise.all([mutateUser(), mutateRoles()]),
+ 'User details refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ }
}, [mutateRoles, mutateUser]);
const openRoleDialog = useCallback(() => {
diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx
index 2b23303ad7..9c24bd6ac1 100644
--- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx
@@ -13,6 +13,8 @@ import { Card } from '@/shared/components/ui/card';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import type { UserStatisticsUser } from '@/shared/types/api';
+import { toast } from '@/shared/components/ui/toast';
+import { refreshWithToast } from '@/shared/utils/refreshWithToast';
import {
DropdownMenu,
DropdownMenuContent,
@@ -309,9 +311,20 @@ const UserStatisticsPage: React.FC = () => {
);
};
- const handleRefresh = () => {
- mutate();
- };
+ const handleRefresh = useCallback(async () => {
+ try {
+ await refreshWithToast(
+ () => mutate(),
+ 'User statistics refreshed successfully'
+ );
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'Unable to refresh user statistics'
+ );
+ }
+ }, [mutate]);
if (isLoading) {
return (
diff --git a/src/platform/src/app/api/users/tokens/security/route.ts b/src/platform/src/app/api/users/tokens/security/route.ts
new file mode 100644
index 0000000000..265cc1bd5a
--- /dev/null
+++ b/src/platform/src/app/api/users/tokens/security/route.ts
@@ -0,0 +1,114 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/shared/lib/auth';
+import { buildServerApiUrl } from '@/shared/lib/api-routing';
+import { normalizeOAuthAccessToken } from '@/shared/lib/oauth-session';
+import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
+import type { Session } from 'next-auth';
+import type { UpdateTokenSecurityRequest } from '@/shared/types/api';
+
+export const dynamic = 'force-dynamic';
+export const revalidate = 0;
+const DEFAULT_PROXY_TIMEOUT_MS = 30000;
+
+type TokenSecurityProxyRequest = UpdateTokenSecurityRequest & {
+ token?: string;
+};
+
+const resolveAuthorizationHeader = (
+ request: NextRequest,
+ session: Session | null
+): string | null => {
+ const authorizationHeader = request.headers.get('authorization');
+ if (authorizationHeader?.trim()) {
+ return authorizationHeader;
+ }
+
+ const accessToken = normalizeOAuthAccessToken(
+ (session as { accessToken?: string } | null)?.accessToken || ''
+ );
+
+ return accessToken ? `JWT ${accessToken}` : null;
+};
+
+export async function PATCH(request: NextRequest) {
+ try {
+ const session = (await getServerSession(authOptions)) as Session | null;
+ const authorizationHeader = resolveAuthorizationHeader(request, session);
+
+ if (!authorizationHeader) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ let body: TokenSecurityProxyRequest;
+ try {
+ body = (await request.json()) as TokenSecurityProxyRequest;
+ } catch {
+ return NextResponse.json(
+ { error: 'Invalid JSON payload' },
+ { status: 400 }
+ );
+ }
+
+ const token = typeof body.token === 'string' ? body.token.trim() : '';
+ if (!token) {
+ return NextResponse.json({ error: 'Missing token' }, { status: 400 });
+ }
+
+ const payload = { ...body };
+ delete payload.token;
+ const upstreamUrl = buildServerApiUrl(
+ `/users/tokens/${encodeURIComponent(token)}`
+ );
+
+ const upstreamAbortController = new AbortController();
+ const timeoutId = setTimeout(() => {
+ upstreamAbortController.abort();
+ }, DEFAULT_PROXY_TIMEOUT_MS);
+
+ let upstreamResponse: Response;
+ try {
+ upstreamResponse = await fetch(upstreamUrl, {
+ method: 'PATCH',
+ cache: 'no-store',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: authorizationHeader,
+ },
+ body: JSON.stringify(payload),
+ signal: upstreamAbortController.signal,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ const responseBody = await upstreamResponse.text();
+
+ return new NextResponse(responseBody, {
+ status: upstreamResponse.status,
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
+ 'Content-Type':
+ upstreamResponse.headers.get('Content-Type') || 'application/json',
+ Expires: '0',
+ Pragma: 'no-cache',
+ },
+ });
+ } catch (error) {
+ if ((error as { name?: string })?.name === 'AbortError') {
+ return NextResponse.json(
+ { error: 'Upstream request timed out' },
+ { status: 504 }
+ );
+ }
+
+ console.error(
+ 'Token security proxy error:',
+ sanitizeErrorForLogging(error)
+ );
+ return NextResponse.json(
+ { error: 'Failed to update token security' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/platform/src/app/globals.css b/src/platform/src/app/globals.css
index ad6e24e47a..24c4ed6890 100644
--- a/src/platform/src/app/globals.css
+++ b/src/platform/src/app/globals.css
@@ -92,7 +92,7 @@ body,
html {
color: rgb(var(--foreground));
background: rgb(var(--background));
- font-family: 'Inter', Arial, Helvetica, sans-serif;
+ font-family: var(--font-inter), Inter, Arial, Helvetica, sans-serif;
}
.dark body,
diff --git a/src/platform/src/app/layout.tsx b/src/platform/src/app/layout.tsx
index 1476b7da8b..5f28ca6bd1 100644
--- a/src/platform/src/app/layout.tsx
+++ b/src/platform/src/app/layout.tsx
@@ -1,6 +1,7 @@
import './globals.css';
import '@smastrom/react-rating/style.css';
import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
import Script from 'next/script';
import NextTopLoader from 'nextjs-toploader';
import { ReduxProvider } from '@/shared/providers/redux-provider';
@@ -15,6 +16,12 @@ import baseMetadata from '@/shared/lib/metadata';
import ErrorBoundary from '@/shared/components/ErrorBoundary';
import AppNetworkGate from '@/shared/components/AppNetworkGate';
+const inter = Inter({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-inter',
+});
+
export const metadata: Metadata = baseMetadata;
export default function RootLayout({
@@ -23,7 +30,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
-
+
;
-}
\ No newline at end of file
+}
diff --git a/src/platform/src/modules/airqo-map/components/map/EnhancedMap.tsx b/src/platform/src/modules/airqo-map/components/map/EnhancedMap.tsx
index 64821686f4..c881f5bf1f 100644
--- a/src/platform/src/modules/airqo-map/components/map/EnhancedMap.tsx
+++ b/src/platform/src/modules/airqo-map/components/map/EnhancedMap.tsx
@@ -24,18 +24,28 @@ import type { AirQualityReading, ClusterData } from './MapNodes';
import { setMapSettings } from '@/shared/store/mapSettingsSlice';
import { selectMapStyle, selectNodeType } from '@/shared/store/selectors';
import type { PollutantType } from '@/shared/utils/airQuality';
+import { toast } from '@/shared/components/ui';
// ─── Constants ────────────────────────────────────────────────────────────────
const MAP_MARKER_Z_INDEX = 20;
-const CLUSTER_ZOOM_THRESHOLD = 14;
+/**
+ * At or above this zoom, no clusters are formed — every reading renders as
+ * its own node. Below it, getClusterParams() decides how aggressively
+ * nearby readings are grouped. Chosen so clusters break up naturally
+ * before the user is close enough to want to click individual stations.
+ */
+const CLUSTER_ZOOM_THRESHOLD = 12;
const NODE_DETAIL_ZOOM = 14.5;
-const AFRICA_BOUNDS: [[number, number], [number, number]] = [
- [-35, -45],
- [60, 45],
-];
/** Debounce delay for clustering recalculation (ms) */
const CLUSTER_DEBOUNCE_MS = 250;
+const INITIAL_VIEW_STATE: Partial = {
+ longitude: 17.61872846571009,
+ latitude: 5.468074924059209,
+ zoom: 2.6680390382433803,
+};
+const RESET_FLY_TO_DURATION_MS = 1000;
+const REFRESH_TIMEOUT_MS = 10000;
// ─── Module-level pure helpers ────────────────────────────────────────────────
@@ -63,15 +73,22 @@ const buildSpatialIndex = (
* Returns clustering parameters that shrink as zoom increases.
* Using integer breakpoints keeps parameter changes discrete, which prevents
* continuous re-clustering during smooth zoom animations.
+ *
+ * distanceKm shrinks aggressively with zoom so clusters naturally fragment
+ * as the user zooms in. gridSize is always comfortably larger than
+ * distanceKm/111 so the spatial index never splits a potential cluster
+ * across two cells. minSize stays at 2 so any two readings within range
+ * always cluster (avoids the "two lonely markers overlapping" look).
*/
const getClusterParams = (zoom: number) => {
const z = Math.floor(zoom);
- if (z < 4) return { gridSize: 4.0, distanceKm: 15.0, minSize: 2 };
- if (z < 6) return { gridSize: 2.0, distanceKm: 8.0, minSize: 2 };
- if (z < 8) return { gridSize: 1.0, distanceKm: 5.0, minSize: 2 };
- if (z < 10) return { gridSize: 0.5, distanceKm: 3.0, minSize: 3 };
- if (z < 12) return { gridSize: 0.2, distanceKm: 1.5, minSize: 3 };
- return { gridSize: 0.1, distanceKm: 0.8, minSize: 4 };
+ if (z < 3) return { gridSize: 4.0, distanceKm: 30.0, minSize: 2 };
+ if (z < 5) return { gridSize: 3.0, distanceKm: 18.0, minSize: 2 };
+ if (z < 7) return { gridSize: 2.0, distanceKm: 9.0, minSize: 2 };
+ if (z < 9) return { gridSize: 1.0, distanceKm: 4.5, minSize: 2 };
+ if (z < 10) return { gridSize: 0.5, distanceKm: 2.0, minSize: 2 };
+ if (z < 11) return { gridSize: 0.2, distanceKm: 1.0, minSize: 2 };
+ return { gridSize: 0.1, distanceKm: 0.4, minSize: 2 }; // zoom 11 → <12 (threshold)
};
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -94,7 +111,7 @@ interface EnhancedMapProps {
export const EnhancedMap: React.FC = ({
className,
- initialViewState = { longitude: 15, latitude: 2, zoom: 0.7 },
+ initialViewState = INITIAL_VIEW_STATE,
airQualityData = [],
onNodeClick,
onClusterClick,
@@ -121,6 +138,7 @@ export const EnhancedMap: React.FC = ({
const [pinnedTooltipId, setPinnedTooltipId] = useState(null);
const [isStyleDialogOpen, setIsStyleDialogOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
+ const isRefreshingRef = useRef(false);
/**
* clusterZoom — debounced and floor-quantised zoom for clustering.
@@ -195,14 +213,22 @@ export const EnhancedMap: React.FC = ({
clusterMemberIds: new Set(),
};
}
- if (clusterZoom >= CLUSTER_ZOOM_THRESHOLD) {
+ // Use the higher of the debounced clusterZoom and the live viewState.zoom
+ // to ensure clusters break up immediately when the user zooms past the
+ // threshold, even if the debounce hasn't fired yet. This prevents the
+ // "cluster didn't break up after clicking" issue.
+ const effectiveZoom = Math.max(
+ clusterZoom,
+ Math.floor(viewState.zoom ?? 3)
+ );
+ if (effectiveZoom >= CLUSTER_ZOOM_THRESHOLD) {
return {
clusters: [] as ClusterData[],
clusterMemberIds: new Set(),
};
}
- const { gridSize, distanceKm, minSize } = getClusterParams(clusterZoom);
+ const { gridSize, distanceKm, minSize } = getClusterParams(effectiveZoom);
const index = buildSpatialIndex(airQualityData, gridSize);
const result: ClusterData[] = [];
@@ -261,7 +287,7 @@ export const EnhancedMap: React.FC = ({
}
return { clusters: result, clusterMemberIds: memberIds };
- }, [airQualityData, clusterZoom, selectedPollutant]);
+ }, [airQualityData, clusterZoom, viewState.zoom, selectedPollutant]);
const soloReadings = useMemo(
() => airQualityData.filter(r => !clusterMemberIds.has(r.id)),
@@ -293,17 +319,55 @@ export const EnhancedMap: React.FC = ({
}, []);
const handleRefreshMap = useCallback(async () => {
- if (!onRefreshData) return;
+ if (!onRefreshData) {
+ toast.error('Refresh not available');
+ return;
+ }
+ if (isRefreshingRef.current) return;
+
+ isRefreshingRef.current = true;
setIsRefreshing(true);
+
+ // GUARANTEED fallback: force-close the overlay after timeout
+ const fallbackTimer = setTimeout(() => {
+ console.warn('Refresh fallback timer triggered - forcing overlay close');
+ isRefreshingRef.current = false;
+ setIsRefreshing(false);
+ }, REFRESH_TIMEOUT_MS);
+
try {
await onRefreshData();
+ toast.success('Map refreshed');
} catch (e) {
+ // Cancellation/abort errors are expected (e.g. on unmount or cohort
+ // switch) — don't surface them as failures per coding guidelines.
+ const isAbort =
+ (e instanceof Error && e.name === 'AbortError') ||
+ (typeof DOMException !== 'undefined' &&
+ e instanceof DOMException &&
+ e.name === 'AbortError');
+ if (isAbort) {
+ return;
+ }
console.error('Map refresh error:', e);
+ toast.error('Failed to refresh map');
} finally {
- if (isMountedRef.current) setIsRefreshing(false);
+ clearTimeout(fallbackTimer);
+ // Force state update even if component might be unmounting
+ isRefreshingRef.current = false;
+ setIsRefreshing(false);
}
}, [onRefreshData]);
+ const handleResetView = useCallback(() => {
+ mapRef.current?.flyTo({
+ center: [INITIAL_VIEW_STATE.longitude!, INITIAL_VIEW_STATE.latitude!],
+ zoom: INITIAL_VIEW_STATE.zoom,
+ duration: RESET_FLY_TO_DURATION_MS,
+ easing: t => t * (2 - t),
+ });
+ }, []);
+
const handleMapStyleToggle = useCallback(
() => setIsStyleDialogOpen(true),
[]
@@ -377,12 +441,17 @@ export const EnhancedMap: React.FC = ({
);
const currentZoom = viewState.zoom ?? 3;
+ // Always fly to at least CLUSTER_ZOOM_THRESHOLD + 1 so the cluster
+ // actually breaks apart into individual nodes on arrival. Tighter
+ // clusters zoom in further so their members are comfortably separated
+ // and clickable. max() against currentZoom + delta keeps the user from
+ // being yanked in from very low zoom levels.
let targetZoom: number;
- if (maxSpan < 0.001) targetZoom = Math.max(16, currentZoom + 3);
- else if (maxSpan < 0.01) targetZoom = Math.max(14, currentZoom + 2.5);
- else if (maxSpan < 0.1) targetZoom = Math.max(12, currentZoom + 2);
- else targetZoom = Math.max(10, currentZoom + 1.5);
- targetZoom = Math.min(17, targetZoom);
+ if (maxSpan < 0.001) targetZoom = 16;
+ else if (maxSpan < 0.01) targetZoom = 15;
+ else if (maxSpan < 0.05) targetZoom = 14;
+ else targetZoom = Math.max(CLUSTER_ZOOM_THRESHOLD + 1, currentZoom + 2.5);
+ targetZoom = Math.min(18, targetZoom);
const centerLon = lons.reduce((a, b) => a + b, 0) / lons.length;
const centerLat = lats.reduce((a, b) => a + b, 0) / lats.length;
@@ -468,10 +537,10 @@ export const EnhancedMap: React.FC = ({
mapboxAccessToken={mapboxAccessToken}
style={{ width: '100%', height: '100%' }}
mapStyle={currentMapStyle}
- maxBounds={AFRICA_BOUNDS}
minZoom={0.5}
maxZoom={18}
attributionControl={false}
+ preserveDrawingBuffer={true}
/**
* interactiveLayerIds stays empty — all interaction is handled
* via React Marker event handlers, not Mapbox layer events.
@@ -539,6 +608,7 @@ export const EnhancedMap: React.FC = ({
onRefreshMap={handleRefreshMap}
onMapStyleToggle={handleMapStyleToggle}
isRefreshing={isRefreshing}
+ onResetView={handleResetView}
/>
diff --git a/src/platform/src/modules/airqo-map/components/map/MapBox.tsx b/src/platform/src/modules/airqo-map/components/map/MapBox.tsx
index 817b4f3e0d..e7e2fc98e1 100644
--- a/src/platform/src/modules/airqo-map/components/map/MapBox.tsx
+++ b/src/platform/src/modules/airqo-map/components/map/MapBox.tsx
@@ -5,11 +5,6 @@ import MapGL from 'react-map-gl/mapbox';
import { cn } from '@/shared/lib/utils';
import 'mapbox-gl/dist/mapbox-gl.css';
-const AFRICA_BOUNDS: [[number, number], [number, number]] = [
- [-35, -45],
- [60, 45],
-];
-
interface MapBoxProps {
className?: string;
initialViewState?: {
@@ -23,9 +18,9 @@ interface MapBoxProps {
export const MapBox: React.FC = ({
className,
initialViewState = {
- longitude: 15,
- latitude: 2,
- zoom: 0.7,
+ longitude: 17.61872846571009,
+ latitude: 5.468074924059209,
+ zoom: 2.6680390382433803,
},
style,
}) => {
@@ -65,7 +60,6 @@ export const MapBox: React.FC = ({
initialViewState={initialViewState}
style={{ width: '100%', height: '100%' }}
mapStyle="mapbox://styles/mapbox/streets-v12"
- maxBounds={AFRICA_BOUNDS}
minZoom={0.5}
maxZoom={18}
attributionControl={false}
diff --git a/src/platform/src/modules/airqo-map/components/map/MapControls.tsx b/src/platform/src/modules/airqo-map/components/map/MapControls.tsx
index 4d64eb9ca9..f56bd043e1 100644
--- a/src/platform/src/modules/airqo-map/components/map/MapControls.tsx
+++ b/src/platform/src/modules/airqo-map/components/map/MapControls.tsx
@@ -8,6 +8,7 @@ import {
AqLink03,
AqPlus,
AqMinus,
+ AqHome01,
} from '@airqo/icons-react';
import { cn } from '@/shared/lib/utils';
import { toast } from '@/shared/components/ui';
@@ -19,6 +20,7 @@ interface MapControlsProps {
onCopyLink?: () => void;
onRefreshMap?: () => void;
onMapStyleToggle?: () => void;
+ onResetView?: () => void;
isRefreshing?: boolean;
className?: string;
}
@@ -30,6 +32,7 @@ export const MapControls: React.FC = ({
onCopyLink,
onRefreshMap,
onMapStyleToggle,
+ onResetView,
isRefreshing = false,
className,
}) => {
@@ -148,6 +151,17 @@ export const MapControls: React.FC = ({
},
2
)}
+
+ {/* Reset view */}
+ {renderControlButton(
+ {
+ icon: AqHome01,
+ label: 'Reset to default view',
+ onClick: onResetView,
+ 'aria-label': 'Reset map to default view',
+ },
+ 3
+ )}
{/* Bottom right controls - grouped zoom controls */}
diff --git a/src/platform/src/modules/airqo-map/data/cities.json b/src/platform/src/modules/airqo-map/data/cities.json
index 23672d3abe..3f9299739b 100644
--- a/src/platform/src/modules/airqo-map/data/cities.json
+++ b/src/platform/src/modules/airqo-map/data/cities.json
@@ -187,4 +187,4 @@
"Jeddah",
"Sydney",
"Melbourne"
-]
\ No newline at end of file
+]
diff --git a/src/platform/src/modules/analytics/hooks/index.ts b/src/platform/src/modules/analytics/hooks/index.ts
index 9ccb8e134f..560b0b4419 100644
--- a/src/platform/src/modules/analytics/hooks/index.ts
+++ b/src/platform/src/modules/analytics/hooks/index.ts
@@ -155,7 +155,8 @@ const buildSiteCardsFromRecentReadings = (
...siteCard,
_id: selectedSite._id,
name: apiDisplayName || fallbackDisplayName,
- search_name: siteCard.search_name || apiDisplayName || fallbackDisplayName,
+ search_name:
+ siteCard.search_name || apiDisplayName || fallbackDisplayName,
location: siteCard.location || buildSiteCardLocation(selectedSite),
country: siteCard.country ?? selectedSite.country,
city: siteCard.city ?? selectedSite.city,
diff --git a/src/platform/src/modules/api-client/ApiClientPage.tsx b/src/platform/src/modules/api-client/ApiClientPage.tsx
index 934bde92de..469db642c9 100644
--- a/src/platform/src/modules/api-client/ApiClientPage.tsx
+++ b/src/platform/src/modules/api-client/ApiClientPage.tsx
@@ -8,13 +8,16 @@ import { Tooltip } from 'flowbite-react';
import { toast } from '@/shared/components/ui';
import { formatDate, parseDate } from '@/shared/utils';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
-import { AqPlus, AqEdit05 } from '@airqo/icons-react';
+import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
+import { AqPlus, AqEdit05, AqShield02 } from '@airqo/icons-react';
import { useClientsByUserId, useGenerateToken } from '@/shared/hooks/useClient';
import InactiveClientDialog from './components/InactiveClientDialog';
import CreateClientDialog from './components/CreateClientDialog';
import EditClientDialog from './components/EditClientDialog';
import TokenDisplay from './components/TokenDisplay';
+import TokenSecurityDialog from './components/TokenSecurityDialog';
import type { Client } from '@/shared/types/api';
+import { clientService } from '@/shared/services/clientService';
import { trackApiClientAction } from '@/shared/utils/enhancedAnalytics';
type TableClient = Client & { id: string };
@@ -43,6 +46,16 @@ const ApiClientPage: React.FC = () => {
isOpen: false,
client: null,
});
+ const [tokenSecurityDialogState, setTokenSecurityDialogState] = useState<{
+ isOpen: boolean;
+ client: Client | null;
+ }>({
+ isOpen: false,
+ client: null,
+ });
+ const [reinstateTokenClientId, setReinstateTokenClientId] = useState<
+ string | null
+ >(null);
const userId = (session?.user as { _id?: string })?._id;
const {
@@ -113,6 +126,41 @@ const ApiClientPage: React.FC = () => {
});
}, []);
+ const handleOpenTokenSecurity = useCallback((client: TableClient) => {
+ if (!client.access_token) {
+ toast.error('Token data is unavailable for this client');
+ return;
+ }
+
+ setTokenSecurityDialogState({
+ isOpen: true,
+ client,
+ });
+ }, []);
+
+ const handleReinstateToken = useCallback(
+ async (client: TableClient) => {
+ const tokenValue = client.access_token?.token;
+ if (!tokenValue) {
+ toast.error('Token data is unavailable for this client');
+ return;
+ }
+
+ setReinstateTokenClientId(client._id);
+ try {
+ await clientService.reinstateToken(tokenValue);
+ toast.success('Token reinstated successfully');
+ await mutate();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Reinstate token error:', sanitizeErrorForLogging(error));
+ } finally {
+ setReinstateTokenClientId(null);
+ }
+ },
+ [mutate]
+ );
+
const renderStatus = useCallback((value: unknown, item: TableClient) => {
// Determine token expired status (prefer server-provided status)
const token = item.access_token;
@@ -151,14 +199,7 @@ const ApiClientPage: React.FC = () => {
const token = item.access_token;
if (token) {
- // Determine expired state: prefer server-provided token_status, otherwise infer from expiry date
- let expired = token.token_status === 'expired';
- if (!expired && token.expires) {
- const expiryDate = parseDate(token.expires);
- if (expiryDate) expired = expiryDate.getTime() <= Date.now();
- }
-
- // Only display token details in this column; action buttons live in Actions column now
+ // Only display token details in this column; action buttons live in the Actions column.
return (
@@ -166,7 +207,10 @@ const ApiClientPage: React.FC = () => {
token={token.token}
expiresAt={token.expires}
tokenStatus={token.token_status}
+ requestPattern={token.request_pattern}
showStatusBadge={false}
+ onReinstate={() => handleReinstateToken(item)}
+ reinstateLoading={reinstateTokenClientId === item._id}
/>
@@ -187,7 +231,13 @@ const ApiClientPage: React.FC = () => {
);
},
- [isGeneratingToken, handleGenerateToken, activeGeneratingTokenId]
+ [
+ isGeneratingToken,
+ handleGenerateToken,
+ activeGeneratingTokenId,
+ handleReinstateToken,
+ reinstateTokenClientId,
+ ]
);
const renderCreatedDate = useCallback((value: unknown, item: TableClient) => {
@@ -281,6 +331,51 @@ const ApiClientPage: React.FC = () => {
const isGeneratingForThis =
isGeneratingToken && activeGeneratingTokenId === item._id;
+ return (
+
+ {token && expired && item.isActive && (
+
+
+ handleGenerateToken(item, 'refresh')}
+ disabled={isGeneratingForThis}
+ fullWidth
+ >
+ {isGeneratingForThis
+ ? 'Regenerating...'
+ : 'Regenerate Token'}
+
+
+
+ )}
+
+ {token && (
+
handleOpenTokenSecurity(item)}
+ fullWidth
+ className="whitespace-nowrap"
+ >
+ Manage Security
+
+ )}
+
+
handleEditClient(item)}
+ className="h-8 w-8 self-start p-1"
+ aria-label={`Edit client ${item.name}`}
+ >
+
+
+
+ );
+
if (token && expired && item.isActive) {
return (
@@ -318,6 +413,7 @@ const ApiClientPage: React.FC = () => {
isGeneratingToken,
activeGeneratingTokenId,
handleGenerateToken,
+ handleOpenTokenSecurity,
]
);
@@ -405,6 +501,25 @@ const ApiClientPage: React.FC = () => {
}}
/>
)}
+
+ {/* Token Security Dialog */}
+ {tokenSecurityDialogState.client?.access_token && (
+
+ setTokenSecurityDialogState({
+ isOpen: false,
+ client: null,
+ })
+ }
+ token={tokenSecurityDialogState.client.access_token}
+ clientName={tokenSecurityDialogState.client.name}
+ onSuccess={() => {
+ setTokenSecurityDialogState({ isOpen: false, client: null });
+ mutate();
+ }}
+ />
+ )}
);
};
diff --git a/src/platform/src/modules/api-client/components/CreateClientDialog.tsx b/src/platform/src/modules/api-client/components/CreateClientDialog.tsx
index e03756c9a2..831bff2f90 100644
--- a/src/platform/src/modules/api-client/components/CreateClientDialog.tsx
+++ b/src/platform/src/modules/api-client/components/CreateClientDialog.tsx
@@ -2,10 +2,10 @@
import React, { useState } from 'react';
import { usePostHog } from 'posthog-js/react';
-import { Button, Input, Dialog } from '@/shared/components/ui';
+import { Button, Input, Dialog, Checkbox } from '@/shared/components/ui';
import { toast } from '@/shared/components/ui';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
-import { isValidIpAddress } from '@/shared/lib/validators';
+import { isValidIpAddress, isValidOriginUrl } from '@/shared/lib/validators';
import { clientService } from '@/shared/services/clientService';
import { trackEvent } from '@/shared/utils/analytics';
import { trackApiClientAction } from '@/shared/utils/enhancedAnalytics';
@@ -27,6 +27,9 @@ const CreateClientDialog: React.FC = ({
const [clientName, setClientName] = useState('');
const [ipAddresses, setIpAddresses] = useState(['']);
const [ipErrors, setIpErrors] = useState(['']);
+ const [enforceOrigin, setEnforceOrigin] = useState(false);
+ const [originAddresses, setOriginAddresses] = useState(['']);
+ const [originErrors, setOriginErrors] = useState(['']);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleAddIpAddress = () => {
@@ -52,6 +55,28 @@ const CreateClientDialog: React.FC = ({
setIpErrors(newIpErrors);
};
+ const handleAddOriginAddress = () => {
+ setOriginAddresses([...originAddresses, '']);
+ setOriginErrors([...originErrors, '']);
+ };
+
+ const handleRemoveOriginAddress = (index: number) => {
+ if (originAddresses.length > 1) {
+ setOriginAddresses(originAddresses.filter((_, i) => i !== index));
+ setOriginErrors(originErrors.filter((_, i) => i !== index));
+ }
+ };
+
+ const handleOriginAddressChange = (index: number, value: string) => {
+ const newOriginAddresses = [...originAddresses];
+ newOriginAddresses[index] = value;
+ setOriginAddresses(newOriginAddresses);
+
+ const newOriginErrors = [...originErrors];
+ newOriginErrors[index] = '';
+ setOriginErrors(newOriginErrors);
+ };
+
const handleSubmit = async () => {
if (!clientName.trim()) {
toast.error('Client name is required');
@@ -61,6 +86,9 @@ const CreateClientDialog: React.FC = ({
setIsSubmitting(true);
try {
const filteredIpAddresses = ipAddresses.filter(ip => ip.trim() !== '');
+ const filteredOrigins = originAddresses.filter(
+ origin => origin.trim() !== ''
+ );
// Validate IP addresses
const newIpErrors = [...ipErrors];
@@ -76,6 +104,26 @@ const CreateClientDialog: React.FC = ({
}
setIpErrors(newIpErrors);
+ const newOriginErrors = [...originErrors];
+ if (enforceOrigin) {
+ if (!filteredOrigins.length) {
+ toast.error('At least one allowed origin is required');
+ setIsSubmitting(false);
+ return;
+ }
+
+ for (let i = 0; i < originAddresses.length; i++) {
+ const origin = originAddresses[i].trim();
+ if (origin && !isValidOriginUrl(origin)) {
+ newOriginErrors[i] = 'Enter a valid origin URL with http or https';
+ hasErrors = true;
+ } else {
+ newOriginErrors[i] = '';
+ }
+ }
+ }
+ setOriginErrors(newOriginErrors);
+
if (hasErrors) {
setIsSubmitting(false);
return;
@@ -84,6 +132,8 @@ const CreateClientDialog: React.FC = ({
const clientData = {
name: clientName.trim(),
...(userId && { user_id: userId }),
+ enforce_origin: enforceOrigin,
+ allowed_origins: enforceOrigin ? filteredOrigins : [],
...(filteredIpAddresses.length > 0 && {
ip_addresses: filteredIpAddresses,
}),
@@ -110,6 +160,10 @@ const CreateClientDialog: React.FC = ({
toast.success('Client created successfully');
setClientName('');
setIpAddresses(['']);
+ setIpErrors(['']);
+ setEnforceOrigin(false);
+ setOriginAddresses(['']);
+ setOriginErrors(['']);
onSuccess?.();
} catch (error) {
toast.error(getUserFriendlyErrorMessage(error));
@@ -124,6 +178,9 @@ const CreateClientDialog: React.FC = ({
setClientName('');
setIpAddresses(['']);
setIpErrors(['']);
+ setEnforceOrigin(false);
+ setOriginAddresses(['']);
+ setOriginErrors(['']);
onClose();
}
};
@@ -211,6 +268,79 @@ const CreateClientDialog: React.FC = ({
restrictions)
+
+
+
+
+
+
+ Enforce origin restriction
+
+
+ When enabled, browser requests must include an Origin header
+ that matches one of the allowed origins below.
+
+
+
+
+ {enforceOrigin && (
+
+
+
+ Allowed origins
+
+
+ Use the full origin with protocol, for example
+ https://app.airqo.net.
+
+
+
+
+ {originAddresses.map((origin, index) => (
+
+
+
+ handleOriginAddressChange(index, e.target.value)
+ }
+ placeholder="https://app.airqo.net"
+ className="w-full"
+ />
+ {originErrors[index] && (
+
+ {originErrors[index]}
+
+ )}
+
+ {originAddresses.length > 1 && (
+
handleRemoveOriginAddress(index)}
+ className="px-3"
+ >
+ Remove
+
+ )}
+
+ ))}
+
+ + Add Origin
+
+
+
+ )}
+
);
diff --git a/src/platform/src/modules/api-client/components/EditClientDialog.tsx b/src/platform/src/modules/api-client/components/EditClientDialog.tsx
index c6b1d960e2..614216a073 100644
--- a/src/platform/src/modules/api-client/components/EditClientDialog.tsx
+++ b/src/platform/src/modules/api-client/components/EditClientDialog.tsx
@@ -5,7 +5,7 @@ import { usePostHog } from 'posthog-js/react';
import { Button, Input, Dialog, Checkbox } from '@/shared/components/ui';
import { toast } from '@/shared/components/ui';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
-import { isValidIpAddress } from '@/shared/lib/validators';
+import { isValidIpAddress, isValidOriginUrl } from '@/shared/lib/validators';
import { clientService } from '@/shared/services/clientService';
import type { Client } from '@/shared/types/api';
import { trackEvent } from '@/shared/utils/analytics';
@@ -107,18 +107,29 @@ const EditClientDialog: React.FC = ({
const [ipAddresses, setIpAddresses] = useState(['']);
const [ipErrors, setIpErrors] = useState(['']);
const [requireSecret, setRequireSecret] = useState(false);
+ const [enforceOrigin, setEnforceOrigin] = useState(false);
+ const [originAddresses, setOriginAddresses] = useState(['']);
+ const [originErrors, setOriginErrors] = useState(['']);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isOpen && client) {
setClientName(client.name);
setRequireSecret(Boolean(client.requireClientSecret));
+ setEnforceOrigin(Boolean(client.enforce_origin));
const ips =
client.ip_addresses && Array.isArray(client.ip_addresses)
? client.ip_addresses
: [];
setIpAddresses(ips.length > 0 ? ips : ['']);
setIpErrors(ips.length > 0 ? ips.map(() => '') : ['']);
+
+ const origins =
+ client.allowed_origins && Array.isArray(client.allowed_origins)
+ ? client.allowed_origins
+ : [];
+ setOriginAddresses(origins.length > 0 ? origins : ['']);
+ setOriginErrors(origins.length > 0 ? origins.map(() => '') : ['']);
}
}, [isOpen, client]);
@@ -145,6 +156,28 @@ const EditClientDialog: React.FC = ({
setIpErrors(newIpErrors);
};
+ const handleAddOriginAddress = () => {
+ setOriginAddresses([...originAddresses, '']);
+ setOriginErrors([...originErrors, '']);
+ };
+
+ const handleRemoveOriginAddress = (index: number) => {
+ if (originAddresses.length > 1) {
+ setOriginAddresses(originAddresses.filter((_, i) => i !== index));
+ setOriginErrors(originErrors.filter((_, i) => i !== index));
+ }
+ };
+
+ const handleOriginAddressChange = (index: number, value: string) => {
+ const newOriginAddresses = [...originAddresses];
+ newOriginAddresses[index] = value;
+ setOriginAddresses(newOriginAddresses);
+
+ const newOriginErrors = [...originErrors];
+ newOriginErrors[index] = '';
+ setOriginErrors(newOriginErrors);
+ };
+
const handleSubmit = async () => {
if (!client) {
toast.error('Client data is missing');
@@ -158,6 +191,7 @@ const EditClientDialog: React.FC = ({
setIsSubmitting(true);
try {
const filteredIpAddresses = ipAddresses.filter(ip => ip.trim() !== '');
+ const filteredOrigins = originAddresses.filter(origin => origin.trim());
// Validate IP addresses
const newIpErrors = [...ipErrors];
@@ -173,6 +207,30 @@ const EditClientDialog: React.FC = ({
}
setIpErrors(newIpErrors);
+ const newOriginErrors = [...originErrors];
+ if (enforceOrigin) {
+ if (!filteredOrigins.length) {
+ toast.error('At least one allowed origin is required');
+ setIsSubmitting(false);
+ return;
+ }
+
+ for (let i = 0; i < originAddresses.length; i++) {
+ const origin = originAddresses[i].trim();
+ if (origin && !isValidOriginUrl(origin)) {
+ newOriginErrors[i] = 'Enter a valid origin URL with http or https';
+ hasErrors = true;
+ } else {
+ newOriginErrors[i] = '';
+ }
+ }
+ } else {
+ newOriginErrors.forEach((_, index) => {
+ newOriginErrors[index] = '';
+ });
+ }
+ setOriginErrors(newOriginErrors);
+
if (hasErrors) {
setIsSubmitting(false);
return;
@@ -181,6 +239,8 @@ const EditClientDialog: React.FC = ({
const clientData = {
name: clientName.trim(),
require_secret: requireSecret,
+ enforce_origin: enforceOrigin,
+ allowed_origins: enforceOrigin ? filteredOrigins : [],
...(filteredIpAddresses.length > 0 && {
ip_addresses: filteredIpAddresses,
}),
@@ -296,6 +356,79 @@ const EditClientDialog: React.FC = ({
+
+
+
+
+
+ Enforce origin restriction
+
+
+ When enabled, browser requests must include an Origin header
+ that matches one of the allowed origins below.
+
+
+
+
+ {enforceOrigin && (
+
+
+
+ Allowed origins
+
+
+ Use the full origin with protocol, for example
+ https://app.airqo.net.
+
+
+
+
+ {originAddresses.map((origin, index) => (
+
+
+
+ handleOriginAddressChange(index, e.target.value)
+ }
+ placeholder="https://app.airqo.net"
+ className="w-full"
+ />
+ {originErrors[index] && (
+
+ {originErrors[index]}
+
+ )}
+
+ {originAddresses.length > 1 && (
+
handleRemoveOriginAddress(index)}
+ className="px-3"
+ >
+ Remove
+
+ )}
+
+ ))}
+
+ + Add Origin
+
+
+
+ )}
+
+
void;
+ reinstateLoading?: boolean;
}
const TokenDisplay: React.FC = ({
token,
expiresAt,
tokenStatus,
+ requestPattern,
showStatusBadge = true,
+ onReinstate,
+ reinstateLoading = false,
}) => {
const copyToClipboard = async () => {
try {
@@ -66,9 +73,46 @@ const TokenDisplay: React.FC = ({
? 'font-mono text-sm truncate min-w-0 bg-yellow-50 text-yellow-800 px-2 py-1 rounded border border-yellow-200'
: 'font-mono text-sm truncate min-w-0 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded border';
+ const isAutoSuspended = Boolean(requestPattern?.auto_suspended);
+ const suspendedAt = requestPattern?.suspended_at
+ ? parseDate(requestPattern.suspended_at)
+ : null;
+ const suspensionReason = requestPattern?.suspension_reason;
+
return (
+ {isAutoSuspended && (
+
+ This token has been suspended due to suspicious activity.
+ {suspensionReason && (
+
+ Reason: {' '}
+ {suspensionReason}
+
+ )}
+ {suspendedAt && (
+
+ Suspended at: {' '}
+ {formatDate(suspendedAt, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ )}
+
+ }
+ className="w-full"
+ />
+ )}
+
{maskedToken}
@@ -111,6 +155,20 @@ const TokenDisplay: React.FC = ({
: '—'}
+
+ {isAutoSuspended && onReinstate && (
+
+
+ Reinstate
+
+
+ )}
);
diff --git a/src/platform/src/modules/api-client/components/TokenSecurityDialog.tsx b/src/platform/src/modules/api-client/components/TokenSecurityDialog.tsx
new file mode 100644
index 0000000000..da0752659a
--- /dev/null
+++ b/src/platform/src/modules/api-client/components/TokenSecurityDialog.tsx
@@ -0,0 +1,513 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ Input,
+ WarningBanner,
+} from '@/shared/components/ui';
+import { toast } from '@/shared/components/ui';
+import { clientService } from '@/shared/services/clientService';
+import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
+import { formatDate } from '@/shared/utils';
+import { isValidOriginUrl } from '@/shared/lib/validators';
+import type {
+ ClientAccessToken,
+ UpdateTokenSecurityRequest,
+} from '@/shared/types/api';
+
+const DAYS = [
+ { value: 0, label: 'Sun' },
+ { value: 1, label: 'Mon' },
+ { value: 2, label: 'Tue' },
+ { value: 3, label: 'Wed' },
+ { value: 4, label: 'Thu' },
+ { value: 5, label: 'Fri' },
+ { value: 6, label: 'Sat' },
+];
+
+const HOURS = Array.from({ length: 24 }, (_, hour) => ({
+ value: hour,
+ label: `${hour.toString().padStart(2, '0')}:00 UTC`,
+}));
+
+interface TokenSecurityDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ token: ClientAccessToken | null;
+ clientName: string;
+ onSuccess?: () => void;
+}
+
+const normalizeValues = (values: string[]) =>
+ Array.from(new Set(values.map(value => value.trim()).filter(Boolean)));
+
+const TokenSecurityDialog: React.FC
= ({
+ isOpen,
+ onClose,
+ token,
+ clientName,
+ onSuccess,
+}) => {
+ const [allowedGrids, setAllowedGrids] = useState(['']);
+ const [allowedCohorts, setAllowedCohorts] = useState(['']);
+ const [allowedOrigins, setAllowedOrigins] = useState(['']);
+ const [allowedGridErrors, setAllowedGridErrors] = useState(['']);
+ const [allowedCohortErrors, setAllowedCohortErrors] = useState([
+ '',
+ ]);
+ const [allowedOriginErrors, setAllowedOriginErrors] = useState([
+ '',
+ ]);
+ const [scheduleEnabled, setScheduleEnabled] = useState(false);
+ const [allowedDays, setAllowedDays] = useState([]);
+ const [startHour, setStartHour] = useState(0);
+ const [endHour, setEndHour] = useState(23);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isReinstating, setIsReinstating] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen || !token) {
+ return;
+ }
+
+ const grids = token.allowed_grids || [];
+ const cohorts = token.allowed_cohorts || [];
+ const origins = token.allowed_origins || [];
+ const schedule = token.access_schedule;
+
+ setAllowedGrids(grids.length > 0 ? grids : ['']);
+ setAllowedGridErrors(grids.length > 0 ? grids.map(() => '') : ['']);
+ setAllowedCohorts(cohorts.length > 0 ? cohorts : ['']);
+ setAllowedCohortErrors(cohorts.length > 0 ? cohorts.map(() => '') : ['']);
+ setAllowedOrigins(origins.length > 0 ? origins : ['']);
+ setAllowedOriginErrors(origins.length > 0 ? origins.map(() => '') : ['']);
+ setScheduleEnabled(Boolean(schedule?.enabled));
+ setAllowedDays(schedule?.allowed_days || []);
+ setStartHour(schedule?.allowed_hours_utc?.start ?? 0);
+ setEndHour(schedule?.allowed_hours_utc?.end ?? 23);
+ }, [isOpen, token]);
+
+ const tokenLabel = token?.name || 'Token security';
+ const isAutoSuspended = Boolean(token?.request_pattern?.auto_suspended);
+
+ const handleListChange = (
+ setter: React.Dispatch>,
+ errorsSetter: React.Dispatch>,
+ index: number,
+ value: string
+ ) => {
+ setter(previous => {
+ const next = [...previous];
+ next[index] = value;
+ return next;
+ });
+
+ errorsSetter(previous => {
+ const next = [...previous];
+ next[index] = '';
+ return next;
+ });
+ };
+
+ const handleAddRow = (
+ setter: React.Dispatch>,
+ errorsSetter: React.Dispatch>
+ ) => {
+ setter(previous => [...previous, '']);
+ errorsSetter(previous => [...previous, '']);
+ };
+
+ const handleRemoveRow = (
+ setter: React.Dispatch>,
+ errorsSetter: React.Dispatch>,
+ values: string[],
+ index: number
+ ) => {
+ if (values.length <= 1) {
+ return;
+ }
+
+ setter(previous => previous.filter((_, i) => i !== index));
+ errorsSetter(previous => previous.filter((_, i) => i !== index));
+ };
+
+ const validateOrigins = (values: string[]) => {
+ const nextErrors = values.map(value =>
+ value.trim() && !isValidOriginUrl(value)
+ ? 'Enter a valid origin URL with http or https'
+ : ''
+ );
+ setAllowedOriginErrors(nextErrors);
+ return !nextErrors.some(Boolean);
+ };
+
+ const handleReinstate = async () => {
+ if (!token) {
+ return;
+ }
+
+ setIsReinstating(true);
+ try {
+ await clientService.reinstateToken(token.token);
+ toast.success('Token reinstated successfully');
+ onSuccess?.();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error('Reinstate token error:', sanitizeErrorForLogging(error));
+ } finally {
+ setIsReinstating(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!token) {
+ toast.error('Token data is unavailable');
+ return;
+ }
+
+ const normalizedGrids = normalizeValues(allowedGrids);
+ const normalizedCohorts = normalizeValues(allowedCohorts);
+ const normalizedOrigins = normalizeValues(allowedOrigins);
+
+ const gridErrors = allowedGrids.map(value => (value.trim() ? '' : ''));
+ const cohortErrors = allowedCohorts.map(value => (value.trim() ? '' : ''));
+ setAllowedGridErrors(gridErrors);
+ setAllowedCohortErrors(cohortErrors);
+
+ if (!validateOrigins(allowedOrigins)) {
+ return;
+ }
+
+ if (scheduleEnabled && startHour === endHour) {
+ toast.error('Select a valid UTC hour range');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const payload: UpdateTokenSecurityRequest = {
+ allowed_grids: normalizedGrids,
+ allowed_cohorts: normalizedCohorts,
+ allowed_origins: normalizedOrigins,
+ access_schedule: {
+ enabled: scheduleEnabled,
+ allowed_days: scheduleEnabled ? allowedDays : [],
+ allowed_hours_utc: {
+ start: scheduleEnabled ? startHour : 0,
+ end: scheduleEnabled ? endHour : 23,
+ },
+ },
+ };
+
+ await clientService.updateTokenSecurity(token.token, payload);
+ toast.success('Token security updated successfully');
+ onSuccess?.();
+ } catch (error) {
+ toast.error(getUserFriendlyErrorMessage(error));
+ console.error(
+ 'Token security update error:',
+ sanitizeErrorForLogging(error)
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const toggleDay = (day: number) => {
+ setAllowedDays(previous =>
+ previous.includes(day)
+ ? previous.filter(value => value !== day)
+ : [...previous, day].sort((a, b) => a - b)
+ );
+ };
+
+ const handleClose = () => {
+ if (!isSubmitting && !isReinstating) {
+ onClose();
+ }
+ };
+
+ const renderListEditor = (
+ label: string,
+ description: string,
+ values: string[],
+ errors: string[],
+ onChange: (index: number, value: string) => void,
+ onAdd: () => void,
+ onRemove: (index: number) => void
+ ) => (
+
+
+
{label}
+
{description}
+
+
+
+ {values.map((value, index) => (
+
+
+
onChange(index, e.target.value)}
+ placeholder="Enter value"
+ className="w-full"
+ />
+ {errors[index] && (
+
+ {errors[index]}
+
+ )}
+
+ {values.length > 1 && (
+
onRemove(index)}
+ className="px-3 sm:self-start"
+ >
+ Remove
+
+ )}
+
+ ))}
+
+
+ + Add Row
+
+
+
+ );
+
+ return (
+
+
+ {isAutoSuspended && token?.request_pattern && (
+
+
+ This token has been suspended because suspicious activity was
+ detected.
+
+ {token.request_pattern.suspension_reason && (
+
+ Reason: {' '}
+ {token.request_pattern.suspension_reason}
+
+ )}
+ {token.request_pattern.suspended_at && (
+
+ Suspended at: {' '}
+ {formatDate(token.request_pattern.suspended_at, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ )}
+
+ }
+ actions={
+
+ Reinstate token
+
+ }
+ />
+ )}
+
+
+ {renderListEditor(
+ 'Restrict to Grids',
+ 'Leave blank to keep the token unrestricted.',
+ allowedGrids,
+ allowedGridErrors,
+ (index, value) =>
+ handleListChange(
+ setAllowedGrids,
+ setAllowedGridErrors,
+ index,
+ value
+ ),
+ () => handleAddRow(setAllowedGrids, setAllowedGridErrors),
+ index =>
+ handleRemoveRow(
+ setAllowedGrids,
+ setAllowedGridErrors,
+ allowedGrids,
+ index
+ )
+ )}
+
+ {renderListEditor(
+ 'Restrict to Cohorts',
+ 'Provide cohort IDs that this token is allowed to access.',
+ allowedCohorts,
+ allowedCohortErrors,
+ (index, value) =>
+ handleListChange(
+ setAllowedCohorts,
+ setAllowedCohortErrors,
+ index,
+ value
+ ),
+ () => handleAddRow(setAllowedCohorts, setAllowedCohortErrors),
+ index =>
+ handleRemoveRow(
+ setAllowedCohorts,
+ setAllowedCohortErrors,
+ allowedCohorts,
+ index
+ )
+ )}
+
+
+ {renderListEditor(
+ 'Allowed origins',
+ 'Use full origins with protocol for browser-based integrations.',
+ allowedOrigins,
+ allowedOriginErrors,
+ (index, value) =>
+ handleListChange(
+ setAllowedOrigins,
+ setAllowedOriginErrors,
+ index,
+ value
+ ),
+ () => handleAddRow(setAllowedOrigins, setAllowedOriginErrors),
+ index =>
+ handleRemoveRow(
+ setAllowedOrigins,
+ setAllowedOriginErrors,
+ allowedOrigins,
+ index
+ )
+ )}
+
+
+
+
+
+
+ Access schedule
+
+
+ When enabled, requests outside the configured UTC window are
+ rejected.
+
+
+
+
+
+
+
+ Allowed days
+
+
+ {DAYS.map(day => (
+
+ toggleDay(day.value)}
+ />
+ {day.label}
+
+ ))}
+
+
+ Leave all days unchecked to allow requests on every day.
+
+
+
+
+
+
+ Start hour UTC
+
+ setStartHour(Number(e.target.value))}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
+ >
+ {HOURS.map(hour => (
+
+ {hour.label}
+
+ ))}
+
+
+
+
+
+ End hour UTC
+
+ setEndHour(Number(e.target.value))}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
+ >
+ {HOURS.map(hour => (
+
+ {hour.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ Empty lists mean the token is unrestricted for that dimension. The
+ access schedule is only enforced when it is enabled.
+
+
+
+
+ );
+};
+
+export default TokenSecurityDialog;
diff --git a/src/platform/src/modules/api-client/components/index.ts b/src/platform/src/modules/api-client/components/index.ts
index 7c143e24df..2be84a8379 100644
--- a/src/platform/src/modules/api-client/components/index.ts
+++ b/src/platform/src/modules/api-client/components/index.ts
@@ -2,3 +2,4 @@ export { default as CreateClientDialog } from './CreateClientDialog';
export { default as EditClientDialog } from './EditClientDialog';
export { default as InactiveClientDialog } from './InactiveClientDialog';
export { default as TokenDisplay } from './TokenDisplay';
+export { default as TokenSecurityDialog } from './TokenSecurityDialog';
diff --git a/src/platform/src/modules/data-visualizer/components/DataVisualizerWorkspace.tsx b/src/platform/src/modules/data-visualizer/components/DataVisualizerWorkspace.tsx
index 85ddac4411..0a5c7b4949 100644
--- a/src/platform/src/modules/data-visualizer/components/DataVisualizerWorkspace.tsx
+++ b/src/platform/src/modules/data-visualizer/components/DataVisualizerWorkspace.tsx
@@ -441,7 +441,9 @@ const getDatasetWorkspaceInsight = (
}
if (!hasTimeColumn) {
- notes.push('Trend charts may be limited because no date field was detected.');
+ notes.push(
+ 'Trend charts may be limited because no date field was detected.'
+ );
}
if (!hasCoordinates) {
@@ -493,11 +495,16 @@ const summarizeDatasetQuality = (insights: DatasetWorkspaceInsight[]) => {
const withoutCoordinates = insights.filter(
insight => !insight.hasCoordinates
);
- const sparseDatasets = insights.filter(insight => insight.sparseColumnCount > 0);
+ const sparseDatasets = insights.filter(
+ insight => insight.sparseColumnCount > 0
+ );
const emptyDatasets = insights.filter(
- insight => insight.readiness === 'limited' && insight.profile.columns.length === 0
+ insight =>
+ insight.readiness === 'limited' && insight.profile.columns.length === 0
);
- const parts = ['Blank cells are skipped automatically so incomplete files can still be explored.'];
+ const parts = [
+ 'Blank cells are skipped automatically so incomplete files can still be explored.',
+ ];
if (withoutMetrics.length > 0) {
parts.push(
@@ -531,7 +538,9 @@ const summarizeDatasetQuality = (insights: DatasetWorkspaceInsight[]) => {
return {
severity:
- withoutMetrics.length > 0 || emptyDatasets.length > 0 ? 'warning' : 'info',
+ withoutMetrics.length > 0 || emptyDatasets.length > 0
+ ? 'warning'
+ : 'info',
message: parts.join(' '),
};
};
@@ -566,15 +575,13 @@ export const DataVisualizerWorkspace: React.FC<
const [lastSavedAt, setLastSavedAt] = React.useState(null);
const [appliedDateRange, setAppliedDateRange] =
React.useState(null);
- const [isTutorialDialogOpen, setIsTutorialDialogOpen] =
- React.useState(false);
+ const [isTutorialDialogOpen, setIsTutorialDialogOpen] = React.useState(false);
const [showDataInspector, setShowDataInspector] = React.useState(false);
const [showFieldGuide, setShowFieldGuide] = React.useState(false);
const [displayMode, setDisplayMode] =
React.useState('focused');
const [toolbarCollapsed, setToolbarCollapsed] = React.useState(false);
- const [toolbarStickyEnabled, setToolbarStickyEnabled] =
- React.useState(false);
+ const [toolbarStickyEnabled, setToolbarStickyEnabled] = React.useState(false);
const [isToolbarFloating, setIsToolbarFloating] = React.useState(false);
// Cycle through friendly status messages while parsing
@@ -669,10 +676,7 @@ export const DataVisualizerWorkspace: React.FC<
}, []);
const normalizeChartsForDatasets = React.useCallback(
- (
- currentCharts: VisualizerChartConfig[],
- nextDatasets: UploadedDataset[]
- ) =>
+ (currentCharts: VisualizerChartConfig[], nextDatasets: UploadedDataset[]) =>
currentCharts.map(chart =>
normalizeChartConfigForDatasets(chart, nextDatasets)
),
@@ -845,7 +849,10 @@ export const DataVisualizerWorkspace: React.FC<
{
...chart,
datasetIds: Array.from(
- new Set([...chart.datasetIds, ...newDatasets.map(d => d.id)])
+ new Set([
+ ...chart.datasetIds,
+ ...newDatasets.map(d => d.id),
+ ])
),
},
nextDatasets
@@ -1127,7 +1134,9 @@ export const DataVisualizerWorkspace: React.FC<
return;
}
- const remainingDatasets = datasets.filter(dataset => dataset.id !== datasetId);
+ const remainingDatasets = datasets.filter(
+ dataset => dataset.id !== datasetId
+ );
const remainingDatasetIds = datasets
.filter(dataset => dataset.id !== datasetId)
.map(dataset => dataset.id);
@@ -1136,7 +1145,9 @@ export const DataVisualizerWorkspace: React.FC<
setCharts(current =>
normalizeChartsForDatasets(
current.map(chart => {
- const nextDatasetIds = chart.datasetIds.filter(id => id !== datasetId);
+ const nextDatasetIds = chart.datasetIds.filter(
+ id => id !== datasetId
+ );
return {
...chart,
datasetIds:
@@ -1287,7 +1298,8 @@ export const DataVisualizerWorkspace: React.FC<
const availableChartTypes = React.useMemo(
() =>
DEFAULT_CHART_ORDER.filter(
- type => type !== 'map' || hasCoordinateColumns(workspaceCoordinateColumns)
+ type =>
+ type !== 'map' || hasCoordinateColumns(workspaceCoordinateColumns)
),
[workspaceCoordinateColumns]
);
@@ -1438,7 +1450,8 @@ export const DataVisualizerWorkspace: React.FC<
{charts.length} view{charts.length === 1 ? '' : 's'}
- Saved {lastSavedAt ? formatDraftSavedAt(lastSavedAt) : 'pending'}
+ Saved{' '}
+ {lastSavedAt ? formatDraftSavedAt(lastSavedAt) : 'pending'}
@@ -1472,7 +1485,10 @@ export const DataVisualizerWorkspace: React.FC<
{availableChartTypes.map(type => (
- addChart(type)}>
+ addChart(type)}
+ >
{CHART_TYPE_LABELS[type]}
))}
@@ -2257,7 +2273,9 @@ export const DataVisualizerWorkspace: React.FC<
{visibleChartItems.map(item => (
diff --git a/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx b/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx
index ab878fc630..e984051f57 100644
--- a/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx
+++ b/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx
@@ -1024,7 +1024,8 @@ export const VisualizerChartCard: React.FC
= ({
label="X axis time or category"
value={chart.xColumn || ''}
onChange={event => {
- const nextXColumn = String(event.target.value) || undefined;
+ const nextXColumn =
+ String(event.target.value) || undefined;
const nextXLabel = formatColumnLabel(nextXColumn);
const currentXLabel = formatColumnLabel(chart.xColumn);
const shouldSyncXAxisLabel =
diff --git a/src/platform/src/modules/data-visualizer/components/VisualizerMapChart.tsx b/src/platform/src/modules/data-visualizer/components/VisualizerMapChart.tsx
index 5b3228a16e..bb31814977 100644
--- a/src/platform/src/modules/data-visualizer/components/VisualizerMapChart.tsx
+++ b/src/platform/src/modules/data-visualizer/components/VisualizerMapChart.tsx
@@ -262,7 +262,9 @@ const getValueDomain = (points: VisualizerMapPoint[]) => {
const min = Math.min(...values);
const max = Math.max(...values);
- return min === max ? { min: Math.max(0, min - 1), max: max + 1 } : { min, max };
+ return min === max
+ ? { min: Math.max(0, min - 1), max: max + 1 }
+ : { min, max };
};
const buildAqiColorExpression = (): ExpressionSpecification =>
@@ -284,9 +286,10 @@ const buildAqiColorExpression = (): ExpressionSpecification =>
AIR_QUALITY_COLORS['no-value'],
] as ExpressionSpecification;
-const buildContinuousColorExpression = (
- valueDomain: { min: number; max: number }
-): ExpressionSpecification =>
+const buildContinuousColorExpression = (valueDomain: {
+ min: number;
+ max: number;
+}): ExpressionSpecification =>
[
'interpolate',
['linear'],
@@ -315,8 +318,14 @@ export const VisualizerMapChart: React.FC = ({
() => getPollutantType(config.metricColumn),
[config.metricColumn]
);
- const points = React.useMemo(() => buildMapPoints(rows, config), [config, rows]);
- const initialViewState = React.useMemo(() => getInitialView(points), [points]);
+ const points = React.useMemo(
+ () => buildMapPoints(rows, config),
+ [config, rows]
+ );
+ const initialViewState = React.useMemo(
+ () => getInitialView(points),
+ [points]
+ );
const pointCollection = React.useMemo(
() => buildPointFeatures(points, pollutantType),
[points, pollutantType]
@@ -416,7 +425,10 @@ export const VisualizerMapChart: React.FC = ({
return (
= ({
1,
],
'heatmap-intensity': 1.1,
- 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 2, 10, 12, 28],
+ 'heatmap-radius': [
+ 'interpolate',
+ ['linear'],
+ ['zoom'],
+ 2,
+ 10,
+ 12,
+ 28,
+ ],
'heatmap-opacity': 0.72,
'heatmap-color': [
'interpolate',
@@ -513,7 +533,15 @@ export const VisualizerMapChart: React.FC = ({
id="uploaded-point-layer"
type="circle"
paint={{
- 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 4, 12, 9],
+ 'circle-radius': [
+ 'interpolate',
+ ['linear'],
+ ['zoom'],
+ 2,
+ 4,
+ 12,
+ 9,
+ ],
'circle-color': pollutantType
? buildAqiColorExpression()
: buildContinuousColorExpression(valueDomain),
@@ -537,10 +565,11 @@ export const VisualizerMapChart: React.FC = ({
>
- {selectedFeature.aqLevel && (() => {
- const Icon = getAirQualityIcon(selectedFeature.aqLevel);
- return ;
- })()}
+ {selectedFeature.aqLevel &&
+ (() => {
+ const Icon = getAirQualityIcon(selectedFeature.aqLevel);
+ return ;
+ })()}
{selectedFeature.label}
@@ -568,10 +597,11 @@ export const VisualizerMapChart: React.FC
= ({
- {averageAirQualityInfo && (() => {
- const Icon = averageAirQualityInfo.icon;
- return ;
- })()}
+ {averageAirQualityInfo &&
+ (() => {
+ const Icon = averageAirQualityInfo.icon;
+ return ;
+ })()}
{metricLabel}
diff --git a/src/platform/src/modules/data-visualizer/utils/chartConfig.ts b/src/platform/src/modules/data-visualizer/utils/chartConfig.ts
index 7ca79d68be..0534e605bd 100644
--- a/src/platform/src/modules/data-visualizer/utils/chartConfig.ts
+++ b/src/platform/src/modules/data-visualizer/utils/chartConfig.ts
@@ -7,10 +7,7 @@ import type {
} from '../types';
import { profileDataset } from './dataProfiling';
import { detectCoordinateColumns } from './geospatial';
-import {
- formatColumnLabel,
- formatMeasurementLabel,
-} from './measurementLabels';
+import { formatColumnLabel, formatMeasurementLabel } from './measurementLabels';
import { getDatasetRowsForChart } from './workspaceData';
const CATEGORY_CHART_TYPES = new Set
([
@@ -37,7 +34,9 @@ const getSelectedDatasets = (
}
const selectedIds = new Set(datasetIds);
- const selectedDatasets = datasets.filter(dataset => selectedIds.has(dataset.id));
+ const selectedDatasets = datasets.filter(dataset =>
+ selectedIds.has(dataset.id)
+ );
return selectedDatasets.length > 0 ? selectedDatasets : datasets;
};
@@ -141,16 +140,21 @@ export const normalizeChartConfigForDatasets = (
formatMeasurementLabel(chart.metricColumn) || 'Measurement';
const nextMetricColumn = keepOrFallback(
chart.metricColumn,
- hasMultipleDatasets ? sharedNumericColumns : workspaceProfile.numericColumns,
+ hasMultipleDatasets
+ ? sharedNumericColumns
+ : workspaceProfile.numericColumns,
workspaceProfile.numericColumns
);
const nextSecondaryMetricColumn = SECONDARY_METRIC_CHART_TYPES.has(chart.type)
? keepOrFallback(
chart.secondaryMetricColumn,
- (hasMultipleDatasets ? sharedNumericColumns : workspaceProfile.numericColumns).filter(
+ (hasMultipleDatasets
+ ? sharedNumericColumns
+ : workspaceProfile.numericColumns
+ ).filter(column => column !== nextMetricColumn),
+ workspaceProfile.numericColumns.filter(
column => column !== nextMetricColumn
- ),
- workspaceProfile.numericColumns.filter(column => column !== nextMetricColumn)
+ )
)
: undefined;
const nextCompareColumn =
@@ -202,7 +206,9 @@ export const normalizeChartConfigForDatasets = (
chart.type === 'map'
? keepOrFallback(
chart.latitudeColumn,
- coordinateColumns.latitudeColumn ? [coordinateColumns.latitudeColumn] : [],
+ coordinateColumns.latitudeColumn
+ ? [coordinateColumns.latitudeColumn]
+ : [],
workspaceProfile.numericColumns,
coordinateColumns.latitudeColumn
)
@@ -211,14 +217,17 @@ export const normalizeChartConfigForDatasets = (
chart.type === 'map'
? keepOrFallback(
chart.longitudeColumn,
- coordinateColumns.longitudeColumn ? [coordinateColumns.longitudeColumn] : [],
+ coordinateColumns.longitudeColumn
+ ? [coordinateColumns.longitudeColumn]
+ : [],
workspaceProfile.numericColumns,
coordinateColumns.longitudeColumn
)
: undefined;
- const nextXAxisDefault = chart.type === 'scatter'
- ? formatMeasurementLabel(nextSecondaryMetricColumn)
- : formatColumnLabel(nextXColumn);
+ const nextXAxisDefault =
+ chart.type === 'scatter'
+ ? formatMeasurementLabel(nextSecondaryMetricColumn)
+ : formatColumnLabel(nextXColumn);
const nextYAxisDefault = formatMeasurementLabel(nextMetricColumn);
return {
diff --git a/src/platform/src/modules/data-visualizer/utils/geospatial.ts b/src/platform/src/modules/data-visualizer/utils/geospatial.ts
index c79f71e80d..d7c5531b54 100644
--- a/src/platform/src/modules/data-visualizer/utils/geospatial.ts
+++ b/src/platform/src/modules/data-visualizer/utils/geospatial.ts
@@ -59,7 +59,9 @@ const getCoordinateNameScore = (
return 5;
}
- if (words.some(word => normalized === word || normalized.endsWith(`_${word}`))) {
+ if (
+ words.some(word => normalized === word || normalized.endsWith(`_${word}`))
+ ) {
return 4;
}
@@ -109,7 +111,9 @@ const chooseCoordinateColumn = (
) => {
const isLatitude = direction === 'latitude';
const names = isLatitude ? LATITUDE_NAMES : LONGITUDE_NAMES;
- const words = isLatitude ? ['lat', 'latitude'] : ['lon', 'lng', 'long', 'longitude'];
+ const words = isLatitude
+ ? ['lat', 'latitude']
+ : ['lon', 'lng', 'long', 'longitude'];
const validator = isLatitude ? isValidLatitude : isValidLongitude;
return columns
diff --git a/src/platform/src/modules/user-profile/components/ProfileForm.tsx b/src/platform/src/modules/user-profile/components/ProfileForm.tsx
index 2ebe692dab..d4a4eaa4fb 100644
--- a/src/platform/src/modules/user-profile/components/ProfileForm.tsx
+++ b/src/platform/src/modules/user-profile/components/ProfileForm.tsx
@@ -314,9 +314,9 @@ const ProfileForm: React.FC = ({ userId }) => {
Profile Picture
- Upload a new profile picture (PNG, JPG, WebP, or AVIF, max 5MB)
- or click the X button to remove it. Click "Save Changes"
- to apply your updates.
+ Upload a new profile picture (PNG, JPG, WebP, or AVIF, max 5MB) or
+ click the X button to remove it. Click "Save Changes" to
+ apply your updates.
diff --git a/src/platform/src/shared/components/NoInternetConnection.tsx b/src/platform/src/shared/components/NoInternetConnection.tsx
index 730bb8b172..b636260872 100644
--- a/src/platform/src/shared/components/NoInternetConnection.tsx
+++ b/src/platform/src/shared/components/NoInternetConnection.tsx
@@ -37,11 +37,7 @@ const NoInternetConnection = ({ onRetry }: NoInternetConnectionProps) => {
automatically once internet is restored.
-
+
Retry
{retryMessage && (
diff --git a/src/platform/src/shared/components/NotificationBanner.tsx b/src/platform/src/shared/components/NotificationBanner.tsx
index 6d8280c24a..9cbb694245 100644
--- a/src/platform/src/shared/components/NotificationBanner.tsx
+++ b/src/platform/src/shared/components/NotificationBanner.tsx
@@ -25,16 +25,14 @@ export interface BaseNotificationBannerProps {
layout?: 'default' | 'github-style';
}
-export interface CustomNotificationBannerProps
- extends BaseNotificationBannerProps {
+export interface CustomNotificationBannerProps extends BaseNotificationBannerProps {
type: 'custom';
title: string;
message: string;
action?: NotificationAction;
}
-export interface PendingInvitesNotificationBannerProps
- extends BaseNotificationBannerProps {
+export interface PendingInvitesNotificationBannerProps extends BaseNotificationBannerProps {
type: 'pending-invites';
}
diff --git a/src/platform/src/shared/components/header/config/pageTitles.ts b/src/platform/src/shared/components/header/config/pageTitles.ts
index a7473cd69b..9ae7e05d60 100644
--- a/src/platform/src/shared/components/header/config/pageTitles.ts
+++ b/src/platform/src/shared/components/header/config/pageTitles.ts
@@ -22,9 +22,13 @@ export const PAGE_TITLES: Record = {
// System routes (platform-wide admin features)
'/system/clients': 'API Clients',
+ '/system/email-configs': 'Email Configuration',
+ '/system/security': 'Security',
'/system/org-requests': 'Organization Requests',
'/system/user-statistics': 'User Statistics',
'/system/feedback': 'Feedback',
+ '/system/surveys': 'Survey Management',
+ '/system/surveys/new': 'Create Survey',
// Admin routes (organization-specific admin features)
'/admin/members': 'Members',
@@ -72,10 +76,22 @@ export const getPageTitle = (pathname: string): string => {
// Handle system routes
if (pathname.startsWith('/system/')) {
+ if (pathname.startsWith('/system/surveys/new')) {
+ return 'Create Survey';
+ }
+
+ if (pathname.startsWith('/system/surveys/') && pathname.endsWith('/edit')) {
+ return 'Edit Survey';
+ }
+
if (pathname.startsWith('/system/feedback/')) {
return 'Feedback Details';
}
+ if (pathname.startsWith('/system/surveys/')) {
+ return 'Survey Details';
+ }
+
if (pathname.startsWith('/system/user-statistics/')) {
return 'User Details';
}
diff --git a/src/platform/src/shared/components/sidebar/components/SidebarContent.tsx b/src/platform/src/shared/components/sidebar/components/SidebarContent.tsx
index 22dc51a6bf..fa830a99fb 100644
--- a/src/platform/src/shared/components/sidebar/components/SidebarContent.tsx
+++ b/src/platform/src/shared/components/sidebar/components/SidebarContent.tsx
@@ -92,10 +92,17 @@ export const SidebarContent: React.FC = ({
);
}
- // Hide system-clients, system-org-requests, and system-user-statistics if user doesn't have AIRQO_SUPER_ADMIN role
+ if (item.id === 'system-surveys') {
+ return isAirQoSuperAdminWithEmail();
+ }
+
+ // Hide system-clients, system-security, system-org-requests, and system-user-statistics if user doesn't have AIRQO_SUPER_ADMIN role
+ // Hide the super-admin-only system items if the user doesn't have AIRQO_SUPER_ADMIN role
if (
[
'system-clients',
+ 'system-email-configs',
+ 'system-security',
'system-org-requests',
'system-user-statistics',
].includes(item.id)
diff --git a/src/platform/src/shared/components/sidebar/config/index.ts b/src/platform/src/shared/components/sidebar/config/index.ts
index 63b31d3a56..659c299ca9 100644
--- a/src/platform/src/shared/components/sidebar/config/index.ts
+++ b/src/platform/src/shared/components/sidebar/config/index.ts
@@ -15,6 +15,8 @@ import {
AqUpload01,
AqFileQuestion02,
AqMessageCheckCircle,
+ AqMail04,
+ AqClipboardCheck,
} from '@airqo/icons-react';
export interface NavItem {
@@ -53,8 +55,8 @@ export interface SidebarConfig {
// User flow sidebar configuration
const userSidebarConfig: NavGroup[] = [
{
- id: 'main',
- label: 'Main',
+ id: 'explore',
+ label: 'Explore',
items: [
{
id: 'home',
@@ -62,12 +64,25 @@ const userSidebarConfig: NavGroup[] = [
href: '/user/home',
icon: AqHomeSmile,
},
+ {
+ id: 'map',
+ label: 'Map',
+ href: '/user/map',
+ icon: AqGlobe05,
+ disabled: false,
+ },
{
id: 'favorites',
label: 'My Favorites',
href: '/user/favorites',
icon: AqStar06,
},
+ ],
+ },
+ {
+ id: 'data',
+ label: 'Data & Analysis',
+ items: [
{
id: 'bulk-export',
label: 'Visualization & Data Export',
@@ -80,13 +95,6 @@ const userSidebarConfig: NavGroup[] = [
href: '/user/data-visualizer',
icon: AqPresentationChart02,
},
- {
- id: 'map',
- label: 'Map',
- href: '/user/map',
- icon: AqGlobe05,
- disabled: false,
- },
],
},
{
@@ -221,8 +229,8 @@ const adminSidebarConfig: NavGroup[] = [
const systemSidebarConfig: NavGroup[] = [
{
- id: 'system',
- label: 'System Management',
+ id: 'system-access',
+ label: 'Access & Security',
items: [
{
id: 'system-clients',
@@ -230,17 +238,41 @@ const systemSidebarConfig: NavGroup[] = [
href: '/system/clients',
icon: AqKey01,
},
+ {
+ id: 'system-security',
+ label: 'Security',
+ href: '/system/security',
+ icon: AqShield02,
+ },
+ ],
+ },
+ {
+ id: 'system-platform',
+ label: 'Platform',
+ items: [
+ {
+ id: 'system-email-configs',
+ label: 'Email Configuration',
+ href: '/system/email-configs',
+ icon: AqMail04,
+ },
{
id: 'system-org-requests',
label: 'Organization Requests',
href: '/system/org-requests',
- icon: AqFileQuestion02,
+ icon: AqClipboardCheck,
},
+ ],
+ },
+ {
+ id: 'system-insights',
+ label: 'Insights',
+ items: [
{
- id: 'system-feedback',
- label: 'Feedback',
- href: '/system/feedback',
- icon: AqMessageCheckCircle,
+ id: 'system-surveys',
+ label: 'Survey Management',
+ href: '/system/surveys',
+ icon: AqFileQuestion02,
},
{
id: 'system-user-statistics',
@@ -248,6 +280,12 @@ const systemSidebarConfig: NavGroup[] = [
href: '/system/user-statistics',
icon: AqPresentationChart02,
},
+ {
+ id: 'system-feedback',
+ label: 'Feedback',
+ href: '/system/feedback',
+ icon: AqMessageCheckCircle,
+ },
],
},
];
@@ -275,6 +313,19 @@ const globalSidebarConfig: NavGroup[] = [
href: '/system/clients',
description: 'Manage API clients across the platform',
},
+ {
+ id: 'system-security',
+ label: 'Security',
+ href: '/system/security',
+ description: 'Review blocked ranges and flagged tokens',
+ },
+ {
+ id: 'system-email-configs',
+ label: 'Email Configuration',
+ href: '/system/email-configs',
+ description:
+ 'Maintain admin CC and application email configuration',
+ },
{
id: 'system-org-requests',
label: 'Organization Requests',
@@ -287,6 +338,13 @@ const globalSidebarConfig: NavGroup[] = [
href: '/system/feedback',
description: 'Review user feedback and reported issues',
},
+ {
+ id: 'system-surveys',
+ label: 'Survey Management',
+ href: '/system/surveys',
+ description:
+ 'Review active surveys, responses, and completion stats',
+ },
{
id: 'system-user-statistics',
label: 'User Statistics',
@@ -348,13 +406,18 @@ export const bottomNavItems: Record<'user' | 'organization', NavItem[]> = {
href: '/user/home',
icon: AqHomeSmile,
},
+ {
+ id: 'map',
+ label: 'Map',
+ href: '/user/map',
+ icon: AqGlobe05,
+ },
{
id: 'favorites',
label: 'Favorites',
href: '/user/favorites',
icon: AqStar06,
},
-
{
id: 'bulk-export',
label: 'Export',
@@ -363,16 +426,10 @@ export const bottomNavItems: Record<'user' | 'organization', NavItem[]> = {
},
{
id: 'data-visualizer',
- label: 'Dataset Visualizer',
+ label: 'Visualizer',
href: '/user/data-visualizer',
icon: AqPresentationChart02,
},
- {
- id: 'map',
- label: 'Map',
- href: '/user/map',
- icon: AqGlobe05,
- },
],
organization: [
{
diff --git a/src/platform/src/shared/components/ui/OopsIcon.tsx b/src/platform/src/shared/components/ui/OopsIcon.tsx
index b640b0854a..b61c93bd4b 100644
--- a/src/platform/src/shared/components/ui/OopsIcon.tsx
+++ b/src/platform/src/shared/components/ui/OopsIcon.tsx
@@ -16,9 +16,7 @@ const OopsIcon: React.FC = ({
className = '',
...props
}) => {
- const svgProps = size
- ? { width: size, height: size }
- : {};
+ const svgProps = size ? { width: size, height: size } : {};
return (
= ({
);
};
-export default OopsIcon;
\ No newline at end of file
+export default OopsIcon;
diff --git a/src/platform/src/shared/components/ui/button.tsx b/src/platform/src/shared/components/ui/button.tsx
index 848706e473..60822d4222 100644
--- a/src/platform/src/shared/components/ui/button.tsx
+++ b/src/platform/src/shared/components/ui/button.tsx
@@ -7,7 +7,7 @@ import { AqLoading02 } from '@airqo/icons-react';
import { useRouter } from 'next/navigation';
interface ButtonProps {
- variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled';
+ variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled' | 'danger';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
loading?: boolean;
@@ -109,6 +109,13 @@ const Button = React.forwardRef(
);
case 'disabled':
return 'bg-muted text-muted-foreground cursor-not-allowed border-muted opacity-50';
+ case 'danger':
+ return clsx(
+ 'border border-red-200 text-red-600 bg-transparent',
+ 'hover:bg-red-50 hover:text-red-700 hover:border-red-300',
+ 'focus:ring-red-500 focus:border-red-500',
+ 'disabled:border-red-100 disabled:text-red-300 disabled:bg-transparent disabled:hover:bg-transparent'
+ );
default: // filled
return clsx(
'bg-primary text-white border-primary',
diff --git a/src/platform/src/shared/components/ui/dialog.tsx b/src/platform/src/shared/components/ui/dialog.tsx
index 0a36f973ab..89dec4527e 100644
--- a/src/platform/src/shared/components/ui/dialog.tsx
+++ b/src/platform/src/shared/components/ui/dialog.tsx
@@ -13,7 +13,7 @@ interface ActionButton {
disabled?: boolean;
className?: string;
padding?: string;
- variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled';
+ variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled' | 'danger';
}
interface CardProps {
@@ -101,7 +101,7 @@ interface ActionButton {
loading?: boolean;
className?: string;
padding?: string;
- variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled';
+ variant?: 'filled' | 'outlined' | 'text' | 'ghost' | 'disabled' | 'danger';
}
interface ReusableDialogProps {
@@ -300,12 +300,10 @@ const ReusableDialog: React.FC = ({
{primaryAction.label}
@@ -315,12 +313,10 @@ const ReusableDialog: React.FC = ({
) : (
{primaryAction.label}
@@ -350,29 +346,29 @@ const ReusableDialog: React.FC = ({
/>
{/* Dialog */}
-
+
= ({
tabIndex={-1}
>
{children}
diff --git a/src/platform/src/shared/components/ui/input.tsx b/src/platform/src/shared/components/ui/input.tsx
index 37f496c701..fdc7957ff3 100644
--- a/src/platform/src/shared/components/ui/input.tsx
+++ b/src/platform/src/shared/components/ui/input.tsx
@@ -2,8 +2,10 @@
import * as React from 'react';
import { AqEye, AqEyeOff, AqAlertCircle } from '@airqo/icons-react';
-interface InputProps
- extends Omit, 'onChange'> {
+interface InputProps extends Omit<
+ React.InputHTMLAttributes,
+ 'onChange'
+> {
label?: string;
error?: string;
type?: string;
diff --git a/src/platform/src/shared/components/ui/play-icon.tsx b/src/platform/src/shared/components/ui/play-icon.tsx
index 4c66588ad5..920371e3be 100644
--- a/src/platform/src/shared/components/ui/play-icon.tsx
+++ b/src/platform/src/shared/components/ui/play-icon.tsx
@@ -33,4 +33,4 @@ const PlayIcon: React.FC = ({
);
};
-export default PlayIcon;
\ No newline at end of file
+export default PlayIcon;
diff --git a/src/platform/src/shared/components/ui/radio.tsx b/src/platform/src/shared/components/ui/radio.tsx
index ca6d2dc0c0..bcf9cc6c16 100644
--- a/src/platform/src/shared/components/ui/radio.tsx
+++ b/src/platform/src/shared/components/ui/radio.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import { cn } from '@/shared/lib/utils';
-interface RadioProps
- extends Omit, 'type'> {
+interface RadioProps extends Omit<
+ React.InputHTMLAttributes,
+ 'type'
+> {
label?: string;
description?: string;
error?: string;
diff --git a/src/platform/src/shared/components/ui/text-input.tsx b/src/platform/src/shared/components/ui/text-input.tsx
index 2cd32ac8c1..c6024f63a7 100644
--- a/src/platform/src/shared/components/ui/text-input.tsx
+++ b/src/platform/src/shared/components/ui/text-input.tsx
@@ -1,7 +1,6 @@
import * as React from 'react';
-interface TextInputProps
- extends React.TextareaHTMLAttributes {
+interface TextInputProps extends React.TextareaHTMLAttributes {
id?: string;
name?: string;
label?: string;
diff --git a/src/platform/src/shared/lib/metadata.ts b/src/platform/src/shared/lib/metadata.ts
index 786a26520c..57f15c3fa0 100644
--- a/src/platform/src/shared/lib/metadata.ts
+++ b/src/platform/src/shared/lib/metadata.ts
@@ -182,6 +182,16 @@ const pageMetadata: Record> = {
description: 'Manage API clients for platform integrations.',
},
},
+ '/system/security': {
+ title: 'Security',
+ description:
+ 'Review blocked IP ranges and investigate flagged tokens across the platform.',
+ openGraph: {
+ title: 'Security | AirQo Analytics',
+ description:
+ 'Review blocked IP ranges and investigate flagged tokens across the platform.',
+ },
+ },
'/system/org-requests': {
title: 'Organization Requests',
description: 'Review and manage organization onboarding requests.',
diff --git a/src/platform/src/shared/lib/validators.ts b/src/platform/src/shared/lib/validators.ts
index cf5c5be9d1..ea8675e233 100644
--- a/src/platform/src/shared/lib/validators.ts
+++ b/src/platform/src/shared/lib/validators.ts
@@ -164,3 +164,47 @@ export const isValidIpAddress = (ip: string): boolean => {
return num >= 0 && num <= 255;
});
};
+
+export const isValidCidrNotation = (cidr: string): boolean => {
+ const trimmed = cidr.trim();
+ const cidrRegex =
+ /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}\/(?:[0-9]|[1-2][0-9]|3[0-2])$/;
+ if (!cidrRegex.test(trimmed)) return false;
+
+ const [ipPart, maskPart] = trimmed.split('/');
+ if (!ipPart || !maskPart) return false;
+
+ if (!isValidIpAddress(ipPart)) return false;
+
+ const mask = Number(maskPart);
+ return Number.isInteger(mask) && mask >= 0 && mask <= 32;
+};
+
+export const isValidAsn = (asn: string): boolean => {
+ const trimmed = asn.trim().toUpperCase();
+ const match = trimmed.match(/^AS(\d{1,10})$/);
+ if (!match) return false;
+
+ const value = Number(match[1]);
+ return Number.isInteger(value) && value >= 1 && value <= 4294967295;
+};
+
+export const isValidOriginUrl = (value: string): boolean => {
+ const trimmed = value.trim();
+ if (!trimmed) return false;
+
+ try {
+ const parsed = new URL(trimmed);
+ return (
+ (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
+ parsed.hostname.length > 0 &&
+ parsed.username === '' &&
+ parsed.password === '' &&
+ parsed.pathname === '/' &&
+ parsed.search === '' &&
+ parsed.hash === ''
+ );
+ } catch {
+ return false;
+ }
+};
diff --git a/src/platform/src/shared/services/adminService.ts b/src/platform/src/shared/services/adminService.ts
index 262fea959a..6401e599f6 100644
--- a/src/platform/src/shared/services/adminService.ts
+++ b/src/platform/src/shared/services/adminService.ts
@@ -19,8 +19,26 @@ import type {
AssignUsersToRoleResponse,
UnassignUsersFromRoleRequest,
UnassignUsersFromRoleResponse,
+ GetBlockedAsnsResponse,
+ CreateBlockedAsnRequest,
+ CreateBlockedAsnResponse,
+ DeleteBlockedAsnResponse,
+ GetFlaggedTokensResponse,
+ ResolveFlaggedTokenRequest,
+ ResolveFlaggedTokenResponse,
} from '../types/api';
+const extractSuccessData = (
+ data: T,
+ fallbackMessage: string
+): T => {
+ if (data?.success === false) {
+ throw new Error(data.message || fallbackMessage);
+ }
+
+ return data;
+};
+
export class AdminService {
private authenticatedClient: ApiClient;
@@ -173,6 +191,102 @@ export class AdminService {
);
return response.data;
}
+
+ // Get blocked ASN/CIDR entries
+ async getBlockedASNs(params?: {
+ active?: boolean;
+ skip?: number;
+ limit?: number;
+ }): Promise {
+ await this.ensureAuthenticated();
+ const query = new URLSearchParams();
+ if (typeof params?.active === 'boolean') {
+ query.set('active', String(params.active));
+ }
+ if (typeof params?.skip === 'number') {
+ query.set('skip', String(params.skip));
+ }
+ if (typeof params?.limit === 'number') {
+ query.set('limit', String(params.limit));
+ }
+
+ const url = query.toString()
+ ? `/tokens/blocked-asns?${query.toString()}`
+ : '/tokens/blocked-asns';
+
+ const response =
+ await this.authenticatedClient.get(url);
+ return extractSuccessData(
+ response.data,
+ 'Failed to get blocked ASN entries'
+ );
+ }
+
+ async createBlockedASN(
+ payload: CreateBlockedAsnRequest
+ ): Promise {
+ await this.ensureAuthenticated();
+ const response =
+ await this.authenticatedClient.post(
+ '/tokens/blocked-asns',
+ payload
+ );
+ return extractSuccessData(
+ response.data,
+ 'Failed to create blocked ASN entry'
+ );
+ }
+
+ async deleteBlockedASN(id: string): Promise {
+ await this.ensureAuthenticated();
+ const response =
+ await this.authenticatedClient.delete(
+ `/tokens/blocked-asns/${encodeURIComponent(id)}`
+ );
+ return extractSuccessData(
+ response.data,
+ 'Failed to delete blocked ASN entry'
+ );
+ }
+
+ async getFlaggedTokens(params?: {
+ resolved?: boolean;
+ skip?: number;
+ limit?: number;
+ }): Promise {
+ await this.ensureAuthenticated();
+ const query = new URLSearchParams();
+ if (typeof params?.resolved === 'boolean') {
+ query.set('resolved', String(params.resolved));
+ }
+ if (typeof params?.skip === 'number') {
+ query.set('skip', String(params.skip));
+ }
+ if (typeof params?.limit === 'number') {
+ query.set('limit', String(params.limit));
+ }
+
+ const url = query.toString()
+ ? `/tokens/flagged-tokens?${query.toString()}`
+ : '/tokens/flagged-tokens';
+
+ const response =
+ await this.authenticatedClient.get(url);
+ return extractSuccessData(response.data, 'Failed to get flagged tokens');
+ }
+
+ async resolveFlaggedToken(
+ id: string,
+ payload: ResolveFlaggedTokenRequest = {}
+ ): Promise {
+ await this.ensureAuthenticated();
+ const response =
+ await this.authenticatedClient.put(
+ `/tokens/flagged-tokens/${encodeURIComponent(id)}/resolve`,
+ payload
+ );
+ return extractSuccessData(response.data, 'Failed to resolve flagged token');
+ }
}
export const adminService = new AdminService();
diff --git a/src/platform/src/shared/services/analyticsService.ts b/src/platform/src/shared/services/analyticsService.ts
index 6aa38ed74e..7e668b6443 100644
--- a/src/platform/src/shared/services/analyticsService.ts
+++ b/src/platform/src/shared/services/analyticsService.ts
@@ -174,8 +174,7 @@ export class AnalyticsService {
);
throw (
- failedResponse?.reason ||
- new Error('Failed to fetch recent readings')
+ failedResponse?.reason || new Error('Failed to fetch recent readings')
);
}
diff --git a/src/platform/src/shared/services/applicationEmailConfigService.ts b/src/platform/src/shared/services/applicationEmailConfigService.ts
new file mode 100644
index 0000000000..cd8a2fc526
--- /dev/null
+++ b/src/platform/src/shared/services/applicationEmailConfigService.ts
@@ -0,0 +1,89 @@
+import { ApiClient, createAuthenticatedClient } from './apiClient';
+import { syncClientSessionToken } from './sessionAuthToken';
+import type {
+ ApiErrorResponse,
+ CreateApplicationEmailConfigurationRequest,
+ CreateApplicationEmailConfigurationResponse,
+ DeleteApplicationEmailConfigurationResponse,
+ GetApplicationEmailConfigurationsResponse,
+ UpdateApplicationEmailConfigurationRequest,
+ UpdateApplicationEmailConfigurationResponse,
+} from '../types/api';
+
+export class ApplicationEmailConfigService {
+ private authenticatedClient: ApiClient;
+
+ constructor() {
+ this.authenticatedClient = createAuthenticatedClient();
+ }
+
+ private async ensureAuthenticated() {
+ await syncClientSessionToken(this.authenticatedClient);
+ }
+
+ async getApplicationEmailConfigurations(): Promise {
+ await this.ensureAuthenticated();
+ const response = await this.authenticatedClient.get<
+ GetApplicationEmailConfigurationsResponse | ApiErrorResponse
+ >('/users/application-email-configs');
+ const data = response.data;
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to load email configurations');
+ }
+
+ return data as GetApplicationEmailConfigurationsResponse;
+ }
+
+ async createApplicationEmailConfiguration(
+ configData: CreateApplicationEmailConfigurationRequest
+ ): Promise {
+ await this.ensureAuthenticated();
+ const response = await this.authenticatedClient.post<
+ CreateApplicationEmailConfigurationResponse | ApiErrorResponse
+ >('/users/application-email-configs', configData);
+ const data = response.data;
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to create email configuration');
+ }
+
+ return data as CreateApplicationEmailConfigurationResponse;
+ }
+
+ async updateApplicationEmailConfiguration(
+ configId: string,
+ configData: UpdateApplicationEmailConfigurationRequest
+ ): Promise {
+ await this.ensureAuthenticated();
+ const response = await this.authenticatedClient.put<
+ UpdateApplicationEmailConfigurationResponse | ApiErrorResponse
+ >(`/users/application-email-configs/${configId}`, configData);
+ const data = response.data;
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to update email configuration');
+ }
+
+ return data as UpdateApplicationEmailConfigurationResponse;
+ }
+
+ async deleteApplicationEmailConfiguration(
+ configId: string
+ ): Promise {
+ await this.ensureAuthenticated();
+ const response = await this.authenticatedClient.delete<
+ DeleteApplicationEmailConfigurationResponse | ApiErrorResponse
+ >(`/users/application-email-configs/${configId}`);
+ const data = response.data;
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to delete email configuration');
+ }
+
+ return data as DeleteApplicationEmailConfigurationResponse;
+ }
+}
+
+export const applicationEmailConfigService =
+ new ApplicationEmailConfigService();
diff --git a/src/platform/src/shared/services/clientService.ts b/src/platform/src/shared/services/clientService.ts
index 25b44d3867..a6886d50e4 100644
--- a/src/platform/src/shared/services/clientService.ts
+++ b/src/platform/src/shared/services/clientService.ts
@@ -20,6 +20,8 @@ import type {
DeleteClientResponse,
RefreshClientSecretResponse,
GetClientByIdResponse,
+ UpdateTokenSecurityRequest,
+ UpdateTokenSecurityResponse,
ApiErrorResponse,
} from '../types/api';
@@ -103,6 +105,64 @@ export class ClientService {
return data as UpdateClientResponse;
}
+ async updateTokenSecurity(
+ token: string,
+ payload: UpdateTokenSecurityRequest
+ ): Promise {
+ await this.ensureAuthenticated();
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, 30000);
+ const authorization = this.authenticatedClient.getAuthToken();
+
+ try {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (authorization) {
+ headers.Authorization = authorization;
+ }
+
+ const response = await fetch('/api/users/tokens/security', {
+ method: 'PATCH',
+ cache: 'no-store',
+ credentials: 'same-origin',
+ headers,
+ body: JSON.stringify({
+ token,
+ ...payload,
+ }),
+ signal: controller.signal,
+ });
+
+ const data = (await response.json()) as
+ | UpdateTokenSecurityResponse
+ | ApiErrorResponse;
+
+ if (!response.ok) {
+ throw new Error(data.message || 'Failed to update token security');
+ }
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to update token security');
+ }
+
+ return data as UpdateTokenSecurityResponse;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+
+ async reinstateToken(token: string): Promise {
+ return this.updateTokenSecurity(token, {
+ request_pattern: {
+ auto_suspended: false,
+ },
+ });
+ }
+
// Activate or deactivate a client
async activateClient(
clientId: string,
diff --git a/src/platform/src/shared/services/deviceService.ts b/src/platform/src/shared/services/deviceService.ts
index f84575820d..009453a380 100644
--- a/src/platform/src/shared/services/deviceService.ts
+++ b/src/platform/src/shared/services/deviceService.ts
@@ -489,7 +489,8 @@ export class DeviceService {
return {
success: true,
- message: 'Cohort details endpoint unavailable; continuing without details',
+ message:
+ 'Cohort details endpoint unavailable; continuing without details',
meta: {
total: 0,
limit: 0,
diff --git a/src/platform/src/shared/services/index.ts b/src/platform/src/shared/services/index.ts
index 676ccd159d..58d9809661 100644
--- a/src/platform/src/shared/services/index.ts
+++ b/src/platform/src/shared/services/index.ts
@@ -12,4 +12,6 @@ export { preferencesService } from './preferencesService';
export { analyticsService } from './analyticsService';
export { maintenanceService } from './maintenanceService';
export { adminService } from './adminService';
+export { surveyService } from './surveyService';
export { subscriptionService } from './subscriptionService';
+export { applicationEmailConfigService } from './applicationEmailConfigService';
diff --git a/src/platform/src/shared/services/surveyService.ts b/src/platform/src/shared/services/surveyService.ts
new file mode 100644
index 0000000000..64632b423a
--- /dev/null
+++ b/src/platform/src/shared/services/surveyService.ts
@@ -0,0 +1,186 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { ApiClient, createAuthenticatedClient } from './apiClient';
+import { syncClientSessionToken } from './sessionAuthToken';
+import type {
+ Survey,
+ SurveyResponseItem,
+ SurveyStats,
+ CreateSurveyRequest,
+ UpdateSurveyRequest,
+} from '../types/api';
+
+const extractResponseData = (
+ data: T,
+ fallbackMessage: string
+): T => {
+ if ('success' in data && data.success === false) {
+ throw new Error(data.message || fallbackMessage);
+ }
+
+ return data;
+};
+
+const normalizeSurveyList = (payload: any): Survey[] => {
+ if (Array.isArray(payload?.surveys)) {
+ return payload.surveys as Survey[];
+ }
+
+ if (Array.isArray(payload?.data?.surveys)) {
+ return payload.data.surveys as Survey[];
+ }
+
+ if (payload?.survey) {
+ return [payload.survey as Survey];
+ }
+
+ if (payload?.data?.survey) {
+ return [payload.data.survey as Survey];
+ }
+
+ return [];
+};
+
+const normalizeSurvey = (payload: any): Survey => {
+ const surveys = normalizeSurveyList(payload);
+ const survey = payload?.survey || payload?.data?.survey || surveys[0];
+
+ if (!survey) {
+ throw new Error('Survey not found');
+ }
+
+ return survey as Survey;
+};
+
+const normalizeSurveyResponses = (payload: any): SurveyResponseItem[] => {
+ if (Array.isArray(payload?.responses)) {
+ return payload.responses as SurveyResponseItem[];
+ }
+
+ if (Array.isArray(payload?.data?.responses)) {
+ return payload.data.responses as SurveyResponseItem[];
+ }
+
+ return [];
+};
+
+const normalizeSurveyStats = (payload: any): SurveyStats => {
+ const stats = payload?.stats || payload?.data?.stats;
+
+ if (!stats) {
+ throw new Error('Survey statistics not found');
+ }
+
+ return stats as SurveyStats;
+};
+
+export class SurveyService {
+ private authenticatedClient: ApiClient;
+
+ constructor() {
+ this.authenticatedClient = createAuthenticatedClient();
+ }
+
+ private async ensureAuthenticated() {
+ await syncClientSessionToken(this.authenticatedClient);
+ }
+
+ async getActiveSurveys(): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.get('/users/surveys');
+
+ const data = extractResponseData(response.data, 'Failed to load surveys');
+
+ return normalizeSurveyList(data);
+ }
+
+ async getSurveyById(surveyId: string): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.get(
+ `/users/surveys/${encodeURIComponent(surveyId)}`
+ );
+
+ const data = extractResponseData(
+ response.data,
+ 'Failed to load survey details'
+ );
+
+ return normalizeSurvey(data);
+ }
+
+ async createSurvey(payload: CreateSurveyRequest): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.post(
+ '/users/surveys',
+ payload
+ );
+
+ const data = extractResponseData(response.data, 'Failed to create survey');
+
+ return normalizeSurvey(data);
+ }
+
+ async updateSurvey(
+ surveyId: string,
+ payload: UpdateSurveyRequest
+ ): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.put(
+ `/users/surveys/${encodeURIComponent(surveyId)}`,
+ payload
+ );
+
+ const data = extractResponseData(response.data, 'Failed to update survey');
+
+ try {
+ return normalizeSurvey(data);
+ } catch {
+ return this.getSurveyById(surveyId);
+ }
+ }
+
+ async deleteSurvey(surveyId: string): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.delete(
+ `/users/surveys/${encodeURIComponent(surveyId)}`
+ );
+
+ extractResponseData(response.data, 'Failed to delete survey');
+ }
+
+ async getSurveyResponses(): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.get(
+ '/users/surveys/responses'
+ );
+
+ const data = extractResponseData(
+ response.data,
+ 'Failed to load survey responses'
+ );
+
+ return normalizeSurveyResponses(data);
+ }
+
+ async getSurveyStats(surveyId: string): Promise {
+ await this.ensureAuthenticated();
+
+ const response = await this.authenticatedClient.get(
+ `/users/surveys/stats/${encodeURIComponent(surveyId)}`
+ );
+
+ const data = extractResponseData(
+ response.data,
+ 'Failed to load survey statistics'
+ );
+
+ return normalizeSurveyStats(data);
+ }
+}
+
+export const surveyService = new SurveyService();
diff --git a/src/platform/src/shared/types/api.ts b/src/platform/src/shared/types/api.ts
index c4316a8fe3..0ccf916f09 100644
--- a/src/platform/src/shared/types/api.ts
+++ b/src/platform/src/shared/types/api.ts
@@ -353,6 +353,8 @@ export interface Client {
ip_addresses: string[];
name: string;
requireClientSecret: boolean;
+ enforce_origin?: boolean;
+ allowed_origins?: string[];
client_secret: string;
user: {
firstName: string;
@@ -368,20 +370,7 @@ export interface Client {
contentLayout: string;
};
};
- access_token?: {
- _id: string;
- permissions: string[];
- scopes: string[];
- expiredEmailSent: boolean;
- token: string;
- client_id: string;
- name: string;
- expires: string;
- createdAt: string;
- updatedAt: string;
- token_status?: 'active' | 'expired';
- __v: number;
- };
+ access_token?: ClientAccessToken;
}
export interface FeedbackSubmissionMetadata extends Record {
@@ -458,6 +447,146 @@ export interface UpdateFeedbackStatusResponse {
feedback: FeedbackSubmission;
}
+export interface SurveyQuestion {
+ id: string;
+ question: string;
+ type: string;
+ options: string[];
+ isRequired: boolean;
+ placeholder?: string;
+ minValue?: number;
+ maxValue?: number;
+}
+
+export interface SurveyQuestionInput {
+ id?: string;
+ question: string;
+ type: string;
+ options?: string[];
+ isRequired: boolean;
+ placeholder?: string;
+ minValue?: number;
+ maxValue?: number;
+}
+
+export interface SurveyTriggerConditions extends Record {
+ threshold?: number;
+ duration?: number;
+}
+
+export interface SurveyTrigger {
+ type: string;
+ conditions?: SurveyTriggerConditions;
+}
+
+export interface Survey {
+ _id: string;
+ timeToComplete: number;
+ isActive: boolean;
+ title: string;
+ description: string;
+ questions: SurveyQuestion[];
+ trigger?: SurveyTrigger | null;
+ expiresAt?: string | null;
+ updatedAt: string;
+ createdAt: string;
+ questionCount?: number;
+ requiredQuestionCount?: number;
+ isExpired?: boolean;
+ surveyStatus?: string;
+}
+
+export interface CreateSurveyRequest {
+ title: string;
+ description?: string;
+ questions: SurveyQuestionInput[];
+ trigger?: SurveyTrigger;
+ timeToComplete?: number;
+ isActive?: boolean;
+ expiresAt?: string;
+}
+
+export interface UpdateSurveyRequest {
+ title?: string;
+ description?: string;
+ isActive?: boolean;
+ questions?: SurveyQuestionInput[];
+ timeToComplete?: number;
+ trigger?: SurveyTrigger;
+ expiresAt?: string;
+}
+
+export interface SurveyAnswer {
+ questionId: string;
+ answer: string | number | boolean | string[] | number[] | null;
+ answeredAt: string;
+}
+
+export interface SurveyResponseUser {
+ _id: string;
+ consent?: {
+ analytics?: boolean;
+ };
+ preferredTokenStrategy?: string;
+ subscriptionStatus?: string;
+ automaticRenewal?: boolean;
+ interests?: string[];
+ cohorts?: string[];
+ email?: string;
+ firstName?: string;
+ lastName?: string;
+ theme?: {
+ primaryColor?: string;
+ mode?: string;
+ interfaceStyle?: string;
+ contentLayout?: string;
+ };
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface SurveyResponseSummary {
+ _id: string;
+ timeToComplete?: number;
+ isActive?: boolean;
+ title?: string;
+ description?: string;
+ expiresAt?: string | null;
+ createdAt?: string;
+}
+
+export interface SurveyResponseItem {
+ _id: string;
+ isGuest?: boolean;
+ status?: string;
+ surveyId: string;
+ userId?: string;
+ deviceId?: string;
+ answers: SurveyAnswer[];
+ startedAt?: string;
+ completedAt?: string | null;
+ timeToComplete?: number;
+ updatedAt: string;
+ createdAt: string;
+ survey?: SurveyResponseSummary;
+ user?: SurveyResponseUser;
+ answerCount?: number;
+ completionEfficiency?: number;
+ submissionDate?: string;
+ hasLocationData?: boolean;
+ hasDeviceTracking?: boolean;
+}
+
+export interface SurveyStats {
+ surveyId: string;
+ totalResponses: number;
+ completedResponses: number;
+ skippedResponses: number;
+ averageCompletionTime: number;
+ completionRate: number;
+ answerDistribution: Record>;
+}
+
export interface Group {
grp_title: string;
organization_slug: string;
@@ -933,6 +1062,8 @@ export interface Client {
ip_addresses: string[];
name: string;
requireClientSecret: boolean;
+ enforce_origin?: boolean;
+ allowed_origins?: string[];
client_secret: string;
user: {
firstName: string;
@@ -948,20 +1079,7 @@ export interface Client {
contentLayout: string;
};
};
- access_token?: {
- _id: string;
- permissions: string[];
- scopes: string[];
- expiredEmailSent: boolean;
- token: string;
- client_id: string;
- name: string;
- expires: string;
- createdAt: string;
- updatedAt: string;
- token_status?: 'active' | 'expired';
- __v: number;
- };
+ access_token?: ClientAccessToken;
}
export interface GetClientsResponse {
@@ -974,6 +1092,8 @@ export interface CreateClientRequest {
name: string;
user_id?: string;
ip_addresses?: string[];
+ enforce_origin?: boolean;
+ allowed_origins?: string[];
}
export interface CreateClientResponse {
@@ -986,6 +1106,8 @@ export interface UpdateClientRequest {
name?: string;
ip_addresses?: string[];
require_secret?: boolean;
+ enforce_origin?: boolean;
+ allowed_origins?: string[];
}
export interface UpdateClientResponse {
@@ -1017,20 +1139,7 @@ export interface GenerateTokenRequest {
export interface GenerateTokenResponse {
success: boolean;
message: string;
- token: {
- _id: string;
- permissions: string[];
- scopes: string[];
- expiredEmailSent: boolean;
- token: string;
- client_id: string;
- name: string;
- expires: string;
- createdAt: string;
- updatedAt: string;
- token_status?: 'active' | 'expired';
- __v: number;
- };
+ token: ClientAccessToken;
}
export interface DeleteClientResponse {
@@ -1050,6 +1159,187 @@ export interface GetClientByIdResponse {
clients: Client[];
}
+export interface TokenAccessSchedule {
+ enabled: boolean;
+ allowed_days: number[];
+ allowed_hours_utc: {
+ start: number;
+ end: number;
+ };
+}
+
+export interface TokenRequestPattern {
+ auto_suspended?: boolean;
+ suspension_reason?: string | null;
+ suspended_at?: string | null;
+}
+
+export interface ClientAccessToken {
+ _id: string;
+ permissions: string[];
+ scopes: string[];
+ expiredEmailSent: boolean;
+ token: string;
+ client_id: string;
+ name: string;
+ expires: string;
+ createdAt: string;
+ updatedAt: string;
+ token_status?: 'active' | 'expired';
+ allowed_grids?: string[];
+ allowed_cohorts?: string[];
+ allowed_origins?: string[];
+ access_schedule?: TokenAccessSchedule;
+ request_pattern?: TokenRequestPattern;
+ __v: number;
+}
+
+export interface UpdateTokenSecurityRequest {
+ allowed_grids?: string[];
+ allowed_cohorts?: string[];
+ allowed_origins?: string[];
+ access_schedule?: {
+ enabled?: boolean;
+ allowed_days?: number[];
+ allowed_hours_utc?: {
+ start?: number;
+ end?: number;
+ };
+ };
+ request_pattern?: {
+ auto_suspended?: boolean;
+ };
+}
+
+export interface UpdateTokenSecurityResponse {
+ success?: boolean;
+ message: string;
+ updated_token: ClientAccessToken;
+}
+
+export interface BlockedAsn {
+ _id: string;
+ provider: string;
+ asn?: string | null;
+ cidr_ranges: string[];
+ reason?: string | null;
+ active: boolean;
+ blockedAt: string;
+ createdAt: string;
+ updatedAt?: string;
+}
+
+export interface GetBlockedAsnsResponse {
+ success?: boolean;
+ message: string;
+ blocked_asns: BlockedAsn[];
+}
+
+export interface CreateBlockedAsnRequest {
+ provider: string;
+ asn?: string;
+ cidr_ranges?: string[];
+ reason?: string;
+ active?: boolean;
+}
+
+export interface CreateBlockedAsnResponse {
+ success?: boolean;
+ message: string;
+ blocked_asn: BlockedAsn;
+}
+
+export interface DeleteBlockedAsnResponse {
+ success?: boolean;
+ message: string;
+ deleted_asn: {
+ _id: string;
+ provider: string;
+ };
+}
+
+export interface FlaggedToken {
+ _id: string;
+ token_suffix: string;
+ ip: string;
+ user_agent: string;
+ honeypot_path: string;
+ service: string;
+ action_taken: string;
+ flagged_at: string;
+ resolved: boolean;
+ resolved_at: string | null;
+ resolution_note: string | null;
+}
+
+export interface GetFlaggedTokensResponse {
+ success?: boolean;
+ message: string;
+ flagged_tokens: FlaggedToken[];
+}
+
+export interface ResolveFlaggedTokenRequest {
+ note?: string;
+}
+
+export interface ResolveFlaggedTokenResponse {
+ success?: boolean;
+ message: string;
+ flagged_token: {
+ _id: string;
+ resolved: boolean;
+ resolved_at: string | null;
+ resolution_note: string | null;
+ };
+}
+
+// Application email configuration types
+export interface ApplicationEmailConfiguration {
+ _id: string;
+ adminCCEmails: string;
+ applicationEmails: string[];
+ createdAt?: string;
+ updatedAt?: string;
+ __v?: number;
+}
+
+export interface GetApplicationEmailConfigurationsResponse {
+ success: boolean;
+ message: string;
+ applicationEmailConfigurations: ApplicationEmailConfiguration[];
+ total?: number;
+}
+
+export interface CreateApplicationEmailConfigurationRequest {
+ adminCCEmails: string;
+ applicationEmails?: string[];
+}
+
+export interface CreateApplicationEmailConfigurationResponse {
+ success: boolean;
+ message: string;
+ applicationEmailConfiguration: ApplicationEmailConfiguration;
+}
+
+export interface UpdateApplicationEmailConfigurationRequest {
+ adminCCEmails?: string;
+ applicationEmails?: string[];
+ addApplicationEmails?: string[];
+ removeApplicationEmails?: string[];
+}
+
+export interface UpdateApplicationEmailConfigurationResponse {
+ success: boolean;
+ message: string;
+ applicationEmailConfiguration: ApplicationEmailConfiguration;
+}
+
+export interface DeleteApplicationEmailConfigurationResponse {
+ success: boolean;
+ message: string;
+ applicationEmailConfiguration: ApplicationEmailConfiguration;
+}
+
// Preferences types
export interface Site {
isFeatured?: boolean;
diff --git a/src/platform/src/shared/utils/refreshWithToast.ts b/src/platform/src/shared/utils/refreshWithToast.ts
new file mode 100644
index 0000000000..d6c07da9a1
--- /dev/null
+++ b/src/platform/src/shared/utils/refreshWithToast.ts
@@ -0,0 +1,9 @@
+import { toast } from '@/shared/components/ui';
+
+export const refreshWithToast = async (
+ action: () => Promise | unknown,
+ successMessage = 'Data refreshed successfully'
+): Promise => {
+ await action();
+ toast.success(successMessage);
+};
diff --git a/src/platform/src/shared/utils/sanitizeErrorForLogging.ts b/src/platform/src/shared/utils/sanitizeErrorForLogging.ts
new file mode 100644
index 0000000000..70e423177f
--- /dev/null
+++ b/src/platform/src/shared/utils/sanitizeErrorForLogging.ts
@@ -0,0 +1,56 @@
+type SanitizedErrorLog = {
+ code?: string | number;
+ message?: string;
+ method?: string;
+ name?: string;
+ status?: number;
+ statusText?: string;
+};
+
+export const sanitizeErrorForLogging = (error: unknown): SanitizedErrorLog => {
+ if (!error || typeof error !== 'object') {
+ return {
+ message: String(error),
+ };
+ }
+
+ const candidate = error as {
+ code?: unknown;
+ message?: unknown;
+ name?: unknown;
+ response?: {
+ status?: unknown;
+ statusText?: unknown;
+ };
+ config?: {
+ method?: unknown;
+ };
+ status?: unknown;
+ };
+
+ const status =
+ typeof candidate.status === 'number'
+ ? candidate.status
+ : typeof candidate.response?.status === 'number'
+ ? candidate.response.status
+ : undefined;
+
+ return {
+ code:
+ typeof candidate.code === 'string' || typeof candidate.code === 'number'
+ ? candidate.code
+ : undefined,
+ message:
+ typeof candidate.message === 'string' ? candidate.message : undefined,
+ method:
+ typeof candidate.config?.method === 'string'
+ ? candidate.config.method.toUpperCase()
+ : undefined,
+ name: typeof candidate.name === 'string' ? candidate.name : undefined,
+ status,
+ statusText:
+ typeof candidate.response?.statusText === 'string'
+ ? candidate.response.statusText
+ : undefined,
+ };
+};
diff --git a/src/platform/tailwind.config.ts b/src/platform/tailwind.config.ts
index 97745f3da0..97d3d89269 100644
--- a/src/platform/tailwind.config.ts
+++ b/src/platform/tailwind.config.ts
@@ -56,7 +56,7 @@ const config: Config = {
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
- sans: ['Inter', ...defaultTheme.fontFamily.sans],
+ sans: ['var(--font-inter)', ...defaultTheme.fontFamily.sans],
},
fontWeight: {
thin: '100',
diff --git a/src/vertex-template/.dockerignore b/src/vertex-template/.dockerignore
new file mode 100644
index 0000000000..79578a8e7b
--- /dev/null
+++ b/src/vertex-template/.dockerignore
@@ -0,0 +1,6 @@
+.git
+.gitignore
+node_modules
+npm-debug.log*
+.vscode
+coverage
\ No newline at end of file
diff --git a/src/vertex-template/.env.example b/src/vertex-template/.env.example
new file mode 100644
index 0000000000..0ae37dbaef
--- /dev/null
+++ b/src/vertex-template/.env.example
@@ -0,0 +1,21 @@
+NEXTAUTH_SECRET=
+NEXTAUTH_URL=http://localhost:3000
+
+NEXT_PUBLIC_SLACK_BOT_TOKEN=
+NEXT_PUBLIC_SLACK_CHANNEL=notifs-airqo-netmanager-web
+SLACK_WEBHOOK_URL=
+
+NEXT_PUBLIC_API_TOKEN=
+NEXT_PUBLIC_API_BASE_URL=https://api.airqo.net
+NEXT_PUBLIC_API_URL=https://staging-vertex.airqo.net/api/v2/
+NEXT_PUBLIC_ENV=development
+NEXT_PUBLIC_ANALYTICS_URL=https://staging-analytics.airqo.net
+NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL=
+NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
+NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED=false
+ADMIN_SECRET=
+NEXT_PUBLIC_CLOUDINARY_NAME=
+NEXT_PUBLIC_CLOUDINARY_PRESET=
+NEXT_PUBLIC_HCAPTCHA_SITE_KEY=
+CLOUDINARY_API_KEY=
+CLOUDINARY_API_SECRET=
diff --git a/src/vertex-template/.eslintrc.json b/src/vertex-template/.eslintrc.json
new file mode 100644
index 0000000000..3722418549
--- /dev/null
+++ b/src/vertex-template/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": ["next/core-web-vitals", "next/typescript"]
+}
diff --git a/src/vertex-template/.gitignore b/src/vertex-template/.gitignore
new file mode 100644
index 0000000000..7acd1072c3
--- /dev/null
+++ b/src/vertex-template/.gitignore
@@ -0,0 +1,125 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Removing the idea
+.idea/
+
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+.env.local
+.env.development
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next/
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Cypress files
+cypress/videos
+cypress/screenshots
+
+# Yarn add output
+.pnp.cjs
+.pnp.loader.mjs
+.yarn/
+.yarnrc.yml
+
+# vscode files
+.vscode/*
+
+.lighthouseci
\ No newline at end of file
diff --git a/src/vertex-template/.prettierignore b/src/vertex-template/.prettierignore
new file mode 100644
index 0000000000..90e6bd2ed2
--- /dev/null
+++ b/src/vertex-template/.prettierignore
@@ -0,0 +1,11 @@
+node_modules
+.next
+dist
+build
+*.log
+.DS_Store
+*.lock
+yarn.lock
+package-lock.json
+coverage
+.nyc_output
\ No newline at end of file
diff --git a/src/vertex-template/.prettierrc b/src/vertex-template/.prettierrc
new file mode 100644
index 0000000000..53b311509d
--- /dev/null
+++ b/src/vertex-template/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 80,
+ "useTabs": false,
+ "bracketSpacing": true,
+ "arrowParens": "avoid"
+}
diff --git a/src/vertex-template/Dockerfile b/src/vertex-template/Dockerfile
new file mode 100644
index 0000000000..dec87e368e
--- /dev/null
+++ b/src/vertex-template/Dockerfile
@@ -0,0 +1,41 @@
+# First stage: Build the app
+FROM node:18-alpine AS builder
+
+# Set the working directory
+WORKDIR /app
+
+# Copy package files and install dependencies
+COPY package*.json ./
+RUN npm ci
+
+
+# Copy the rest of the application code
+COPY . .
+
+# Build the Next.js app
+RUN npm run build
+
+# Second stage: Production container
+FROM node:18-alpine AS runner
+
+# Set the working directory
+WORKDIR /app
+
+# Copy built files from builder stage
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package*.json ./
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/start-next.js ./start-next.js
+
+# Add healthcheck
+HEALTHCHECK --interval=30s --timeout=3s \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
+
+USER node
+
+# Expose the application port
+EXPOSE 3000
+
+# Command to run the application
+CMD ["npm", "run", "start"]
diff --git a/src/vertex-template/README.md b/src/vertex-template/README.md
new file mode 100644
index 0000000000..889af50aff
--- /dev/null
+++ b/src/vertex-template/README.md
@@ -0,0 +1,92 @@
+# Vertex (Web App).
+
+`vertex` is the AirQo web application for Device and Network management.
+
+## Quick start
+
+1. Install dependencies:
+
+```bash
+cd src/vertex
+npm install
+```
+
+2. Create local environment file:
+
+```bash
+copy .env.example .env
+```
+
+3. Run development server:
+
+```bash
+npm run dev
+```
+
+4. Open `http://localhost:3000`.
+
+## Scripts
+
+- `npm run dev`: Start local development server.
+- `npm run dev:inspect`: Start dev server with Node inspector enabled.
+- `npm run build`: Build production bundle.
+- `npm run start`: Start production server from built output.
+- `npm run lint`: Run lint checks.
+- `npm run format`: Format code with Prettier.
+- `npm run format:check`: Check formatting.
+- `npm run check-size`: Build and run bundle size checks.
+
+## Environment variables
+
+Use `src/vertex/.env.example` as the base. Common variables include:
+
+- `NEXT_PUBLIC_API_URL`: Backend API base URL.
+- `NEXT_PUBLIC_API_BASE_URL`: Public API origin used for measurement URL examples.
+- `NEXT_PUBLIC_ANALYTICS_URL`: Analytics platform URL.
+- `NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL`: Optional Windows installer URL for Vertex Desktop downloads.
+- `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`: Mapbox token for map features.
+- `NEXT_PUBLIC_ENV`: App environment label (for example `development`).
+- `NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED`: Enables mock permissions when needed.
+- `ADMIN_SECRET`: Secret used by admin/protected server operations.
+- `NEXT_PUBLIC_CLOUDINARY_NAME`: Cloudinary cloud name.
+- `NEXT_PUBLIC_CLOUDINARY_PRESET`: Cloudinary upload preset.
+- `NEXT_PUBLIC_HCAPTCHA_SITE_KEY`: HCaptcha site key needed for the new login flow.
+- `SLACK_WEBHOOK_URL`: Slack webhook for server-side notifications.
+- `NEXT_PUBLIC_SLACK_BOT_TOKEN`, `NEXT_PUBLIC_SLACK_CHANNEL`: Slack client configuration.
+
+## Vertex configuration
+
+Vertex has a typed app configuration surface in `vertex.config.ts`. This is the file that the future `create-vertex-app` CLI will generate or update for each scaffolded instance.
+
+V1 supports two data adapters:
+
+- `mock`: runs locally without API credentials and is the default for generated templates.
+- `airqo`: uses the existing AirQo API, auth, and proxy behavior.
+
+Generic REST backends are intentionally out of scope for v1.
+
+Use `vertex.config.example.ts` as the template-facing reference. Keep validation and shared types in `core/config/vertex-config.ts` so contributors can add config-driven behavior without inventing new config shapes.
+
+## Authentication and SSO
+
+For normal app-local authentication, set:
+
+```bash
+NEXTAUTH_SECRET=
+NEXTAUTH_URL=http://localhost:3001
+```
+
+Notes:
+
+- `NEXTAUTH_SECRET` should be unique to Vertex.
+- `NEXTAUTH_URL` should match the local or production origin for Vertex.
+
+## Related projects
+
+- `src/vertex-desktop`: Electron wrapper that loads the hosted Vertex web app.
+- Most feature and business logic remains in this web app; desktop-specific behavior stays in `src/vertex-desktop`.
+
+## Deployment notes
+
+- Staging and production deployment automation is configured in `.github/workflows/`.
+- This app is deployed independently from desktop installer releases.
diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx
new file mode 100644
index 0000000000..f9ee003f32
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import DeviceDetailsLayout from "@/components/features/devices/device-details-layout";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+export default function AdminCohortDeviceDetailsPage() {
+ const params = useParams();
+ const deviceId = params?.deviceId;
+
+ if (!deviceId || typeof deviceId !== 'string') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx
new file mode 100644
index 0000000000..29b204c19e
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { AqArrowLeft, AqMinusCircle, AqPlus } from "@airqo/icons-react";
+import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { useCohortDetails } from "@/core/hooks/useCohorts";
+import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table";
+import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card";
+import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal";
+import { usePermission } from "@/core/hooks/usePermissions";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { UnassignCohortDevicesDialog } from "@/components/features/cohorts/unassign-cohort-devices";
+import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card";
+import { usePageTitle } from "@/context/page-title-context";
+import { CohortOrganizationsCard } from "@/components/features/cohorts/cohort-organizations-card";
+
+// Loading skeleton for content grid
+const ContentGridSkeleton = () => (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function CohortDetailsPage() {
+ const router = useRouter();
+ const params = useParams();
+ const cohortId = params?.id as string;
+
+ const { data: cohort, isLoading, error } = useCohortDetails(cohortId);
+
+ usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" });
+
+ const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE);
+ const [cohortDetails, setCohortDetails] = useState<{
+ name: string;
+ id: string;
+ visibility: boolean;
+ cohort_tags: string[];
+ }>({
+ name: "",
+ id: "",
+ visibility: true,
+ cohort_tags: [],
+ });
+ const [showDetailsModal, setShowDetailsModal] = useState(false);
+ const [showAssignDialog, setShowAssignDialog] = useState(false);
+ const [showUnassignDialog, setShowUnassignDialog] = useState(false);
+
+ const handleOpenDetails = () => setShowDetailsModal(true);
+ const handleCloseDetails = () => setShowDetailsModal(false);
+
+ useEffect(() => {
+ if (cohort) {
+ setCohortDetails({
+ name: cohort.name,
+ id: cohort._id,
+ visibility: cohort.visibility,
+ cohort_tags: cohort.cohort_tags || [],
+ });
+ }
+ }, [cohort]);
+
+ const devices = useMemo(() => cohort?.devices || [], [cohort]);
+
+ const handleAssignSuccess = () => {
+ setShowAssignDialog(false);
+ };
+
+ const handleUnassignSuccess = () => {
+ setShowUnassignDialog(false);
+ };
+
+ return (
+
+
+
+ router.back()} Icon={AqArrowLeft}>
+ Back
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ Unable to load cohort details:{" "}
+ {String((error as Error)?.message || "Unknown error")}
+
+ ) : (
+
+ {/* Cards section */}
+
+
+
+
+
+
+ {/* Devices list */}
+
+
+
Cohort devices
+
+ setShowAssignDialog(true)}
+ disabled={!canUpdateDevice}
+ permission={PERMISSIONS.DEVICE.UPDATE}
+ Icon={AqPlus}
+ padding="px-3 py-1.5"
+ className="text-sm font-medium"
+ >
+ Add Devices
+
+ setShowUnassignDialog(true)}
+ disabled={!canUpdateDevice}
+ permission={PERMISSIONS.DEVICE.UPDATE}
+ Icon={AqMinusCircle}
+ padding="px-3 py-1.5"
+ className="border-red-700 hover:bg-red-700 text-red-700 text-sm font-medium"
+ >
+ Remove Devices
+
+
+
+
{
+ router.push(`/admin/cohorts/${cohortId}/devices/${device._id}`);
+ }}
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx
new file mode 100644
index 0000000000..588b38e098
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx
@@ -0,0 +1,306 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
+import { CreateCohortDialog } from "@/components/features/cohorts/create-cohort";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable";
+import { useCohorts, useUserCohorts } from "@/core/hooks/useCohorts";
+import { Cohort } from "@/app/types/cohorts";
+import { useState, useMemo } from "react";
+import { format } from 'date-fns';
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqPlus } from "@airqo/icons-react";
+import { CreateCohortFromSelectionDialog } from "@/components/features/cohorts/create-cohort-from-cohorts";
+import { AssignCohortsToGroupDialog } from "@/components/features/cohorts/assign-cohorts-to-group";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import { usePageTitle } from "@/context/page-title-context";
+
+import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices";
+
+type CohortRow = {
+ id: string;
+ name: string;
+ numberOfDevices: number;
+ visibility: boolean;
+ cohort_tags?: string[];
+ dateCreated?: string;
+}
+
+export default function CohortsPage() {
+ usePageTitle({ title: "Cohorts", section: "Administrative Panel" });
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+
+ const {
+ pagination, setPagination,
+ searchTerm, setSearchTerm,
+ sorting, setSorting
+ } = useServerSideTableState({ initialPageSize: 25 });
+
+ const [view, setView] = useState<'organization' | 'user'>('organization');
+
+ // Tag Logic
+ const defaultTag = DEFAULT_COHORT_TAGS[0]?.value || "All";
+ const urlTag = searchParams.get('tags');
+ const selectedTag = urlTag || defaultTag;
+
+ const handleTagClick = (tag: string) => {
+ const params = new URLSearchParams(searchParams);
+ if (tag === 'All') {
+ params.delete('tags');
+ params.set('tags', 'All');
+ } else {
+ params.set('tags', tag);
+ }
+ setPagination(prev => ({ ...prev, pageIndex: 0 }));
+ router.replace(`${pathname}?${params.toString()}`);
+ };
+
+ // Count Queries (Stable, always enabled, minimal payload, no search/sort)
+ const { meta: orgCountMeta, isFetching: isFetchingOrgCount } = useCohorts({
+ page: 1,
+ limit: 1,
+ });
+
+ const { meta: userCountMeta, isFetching: isFetchingUserCount } = useUserCohorts({
+ page: 1,
+ limit: 1,
+ });
+
+ // Table Queries (Dynamic, enabled only when active)
+ const { cohorts: orgCohorts, meta: orgMeta, isFetching: isFetchingOrg, error: orgError } = useCohorts({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ tags: selectedTag === "All" ? undefined : selectedTag,
+ }, {
+ enabled: view === 'organization'
+ });
+
+ const { cohorts: userCohorts, meta: userMeta, isFetching: isFetchingUser, error: userError } = useUserCohorts({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ }, {
+ enabled: view === 'user'
+ });
+
+ const cohorts = view === 'organization' ? orgCohorts : userCohorts;
+ const meta = view === 'organization' ? orgMeta : userMeta;
+ const isFetching = view === 'organization' ? isFetchingOrg : isFetchingUser;
+ const error = view === 'organization' ? orgError : userError;
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ const [showCreateCohortModal, setShowCreateCohortModal] = useState(false);
+ const [showCreateFromCohorts, setShowCreateFromCohorts] = useState(false);
+ const [showAssignToGroup, setShowAssignToGroup] = useState(false);
+ const [selectedCohortIds, setSelectedCohortIds] = useState([]);
+
+ const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({
+ ...c,
+ id: c._id,
+ dateCreated: c.createdAt,
+ })), [cohorts]);
+
+ const columns: TableColumn[] = [
+ {
+ key: "name",
+ label: "Cohort Name",
+ sortable: true,
+ render: (v) => v ?? "-"
+ },
+ {
+ key: "numberOfDevices",
+ label: "Number of devices",
+ sortable: true,
+ render: (v) => (v ?? 0)
+ },
+ {
+ key: "visibility",
+ label: "Visibility",
+ sortable: true,
+ render: (v) => (
+ {v ? "Public" : "Private"}
+ )
+ },
+ {
+ key: "cohort_tags",
+ label: "Tags",
+ sortable: true,
+ render: (value) => {
+ const tags = Array.isArray(value) ? value : [];
+ if (tags.length === 0) return "-";
+ return (
+
+ {tags.map((tag, index) => {
+ const normalized = String(tag || "").replace(/_/g, " ");
+ const displayTag = normalized.toLowerCase() === "external device" ? "misc" : normalized;
+ return (
+
+ {displayTag}
+
+ );
+ })}
+
+ );
+ }
+ },
+ {
+ key: "dateCreated",
+ label: "Date created",
+ sortable: true,
+ render: (value) => {
+ const date = new Date(value as string);
+ return format(date, "MMM d yyyy, h:mm a");
+ }
+ }
+ ]
+
+ const tableActions = [
+ {
+ label: "Create cohort from selection",
+ value: "create-from-cohorts",
+ handler: (ids: (string | number)[]) => {
+ setSelectedCohortIds(ids.map(String));
+ setShowCreateFromCohorts(true);
+ },
+ },
+ {
+ label: "Assign to Organization",
+ value: "assign-to-group",
+ handler: (ids: (string | number)[]) => {
+ setSelectedCohortIds(ids.map(String));
+ setShowAssignToGroup(true);
+ },
+ },
+ ];
+
+ return (
+
+
+
+
+
Cohorts
+
+ Manage and organize your device cohorts
+
+
+ {
+ setView('organization');
+ setPagination(prev => ({ ...prev, pageIndex: 0 }));
+ setSearchTerm("");
+ handleTagClick("organizational");
+ }}
+ className={`flex items-center gap-1 px-4 py-2 rounded-md text-sm font-medium transition-colors border ${view === 'organization'
+ ? "bg-primary text-white border-primary"
+ : "bg-transparent text-primary border-primary hover:bg-primary/10"
+ }`}
+ >
+ Managed Cohorts
+
+ ({isFetchingOrgCount && orgCountMeta?.total === undefined ? (
+
+ ) : (
+ orgCountMeta?.total ?? 0
+ )})
+
+
+ {
+ setView('user');
+ setPagination(prev => ({ ...prev, pageIndex: 0 }));
+ setSearchTerm("");
+ handleTagClick("All");
+ }}
+ className={`flex items-center gap-1 px-4 py-2 rounded-md text-sm font-medium transition-colors border ${view === 'user'
+ ? "bg-primary text-white border-primary"
+ : "bg-transparent text-primary border-primary hover:bg-primary/10"
+ }`}
+ >
+ User Cohorts
+
+ ({isFetchingUserCount && userCountMeta?.total === undefined ? (
+
+ ) : (
+ userCountMeta?.total ?? 0
+ )})
+
+
+
+
+ {view === 'organization' && (
+
+ {[...DEFAULT_COHORT_TAGS.map(t => ({ value: t.value, label: t.label })), { value: 'All', label: 'All Cohorts' }].map(({ value: tag, label }) => (
+ handleTagClick(tag)}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors whitespace-nowrap ${selectedTag === tag
+ ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary ring-1 ring-primary/20"
+ : "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
+ }`}
+ >
+ {label.charAt(0).toUpperCase() + label.slice(1)}
+
+ ))}
+
+ )}
+
+
{
+ setShowCreateCohortModal(true);
+ }}
+ Icon={AqPlus}
+ >
+ Create Cohort
+
+
+
+
+ {
+ const row = item as CohortRow;
+ if (row?.id) router.push(`/admin/cohorts/${row.id}`)
+ }}
+ emptyState={error ? (error.message || "unable to load cohorts") : "No cohorts available"}
+ multiSelect
+ onSelectedIdsChange={(ids: (string | number)[]) => setSelectedCohortIds(ids.map(String))}
+ actions={tableActions}
+ serverSidePagination
+ pageCount={pageCount}
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ onSearchChange={setSearchTerm}
+ searchTerm={searchTerm}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ searchable
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx
new file mode 100644
index 0000000000..8feda80022
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { useGridDetails } from "@/core/hooks/useGrids";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqArrowLeft } from "@airqo/icons-react";
+import GridDetailsCard from "@/components/features/grids/grid-details-card";
+import GridMeasurementsApiCard from "@/components/features/grids/grid-measurements-api-card";
+import EditGridDetailsDialog from "@/components/features/grids/edit-grid-details-dialog";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import ClientPaginatedSitesTable from "@/components/features/sites/client-paginated-sites-table";
+import { usePageTitle } from "@/context/page-title-context";
+
+const ContentGridSkeleton = () => (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function GridDetailsPage() {
+ const router = useRouter();
+ const { id } = useParams();
+ const gridId = id.toString();
+ const { gridDetails, isLoading, error } = useGridDetails(gridId);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ usePageTitle({ title: gridDetails?.name || "Grid Details", section: "Grids" });
+
+ if (error) {
+ return (
+
+
+
+ Error
+ {error?.message}
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+ router.back()} Icon={AqArrowLeft}>
+ Back
+
+
+
+ {isLoading ? (
+
+ ) : !gridDetails ? null :
+ <>
+
+
+ setIsEditDialogOpen(true)} loading={isLoading} />
+
+
+
+
+
+
+
+
setIsEditDialogOpen(false)} grid={gridDetails} />
+ >
+ }
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/grids/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx
new file mode 100644
index 0000000000..ceda910a0c
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { CreateGridForm } from "@/components/features/grids/create-grid";
+import { CreateAdminLevel } from "@/components/features/grids/create-admin-level";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import GridsTable from "@/components/features/grids/grids-list-table";
+
+export default function GridsPage() {
+ return (
+
+
+
+
+
Grids
+
+ Manage and organize your monitoring grids
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/layout.tsx b/src/vertex-template/app/(authenticated)/admin/layout.tsx
new file mode 100644
index 0000000000..71c1c29cea
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/layout.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+
+/**
+ * Admin Layout
+ *
+ * This layout wraps all admin pages with a RouteGuard to ensure
+ * that only AIRQO ADMIN users in the personal context (with airqo group active)
+ * can access admin features.
+ *
+ * Applies to all pages in the /admin/* routes.
+ */
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx
new file mode 100644
index 0000000000..8c1cad41b9
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import DeviceDetailsLayout from "@/components/features/devices/device-details-layout";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+export default function AdminNetworkDeviceDetailsPage() {
+ const params = useParams();
+ const deviceId = params?.deviceId;
+
+ if (!deviceId || typeof deviceId !== 'string') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx
new file mode 100644
index 0000000000..c5484e91e1
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useMemo } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { AqArrowLeft } from "@airqo/icons-react";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import NetworkDetailsCard from "@/components/features/networks/network-detail-card";
+import NetworkDevicesTable from "@/components/features/networks/network-device-list-table";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { Plus, Upload } from "lucide-react";
+import CreateDeviceModal from "@/components/features/devices/create-device-modal";
+import ImportDeviceModal from "@/components/features/devices/import-device-modal";
+import { useState } from "react";
+import { usePermission } from "@/core/hooks/usePermissions";
+import { NetworkStatsCards } from "@/components/features/networks/NetworkStatsCards";
+import { usePageTitle } from "@/context/page-title-context";
+
+// Loading skeleton for content grid
+const ContentGridSkeleton = () => (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function NetworkDetailsPage() {
+ const router = useRouter();
+ const params = useParams();
+ const networkId = params?.id as string;
+
+ const { networks, isLoading, error } = useNetworks();
+ const [isCreateDeviceOpen, setCreateDeviceOpen] = useState(false);
+ const [isImportDeviceOpen, setImportDeviceOpen] = useState(false);
+ const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE);
+
+ const network = useMemo(() => {
+ return networks.find((n) => n._id === networkId);
+ }, [networks, networkId]);
+ usePageTitle({
+ title: network?.net_name || "Sensor Manufacturer Details",
+ section: "Sensor Manufacturers",
+ });
+
+ const isAirQoNetwork = network?.net_name?.toLowerCase() === "airqo";
+
+ return (
+
+
+
+
router.push("/admin/networks")} Icon={AqArrowLeft}>
+ Back
+
+
+ {isAirQoNetwork ? (
+ setCreateDeviceOpen(true)}
+ Icon={Plus}
+ permission={PERMISSIONS.DEVICE.UPDATE}
+ >
+ Add AirQo Device
+
+ ) : (
+ setImportDeviceOpen(true)}
+ Icon={Upload}
+ permission={PERMISSIONS.DEVICE.UPDATE}
+ >
+ Import External Device
+
+ )}
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ Unable to load Sensor Manufacturer details:{" "}
+ {String((error as Error)?.message || "Unknown error")}
+
+ ) : !network ? (
+
+ Sensor Manufacturer not found
+
+ ) : (
+
+ {/* Network basic info card */}
+
+
+
+
+ {/* Network Stats Cards */}
+
+
+
+
+ {/* Devices list */}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/networks/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx
new file mode 100644
index 0000000000..e3f26ffb44
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import ClientPaginatedNetworksTable from "@/components/features/networks/client-paginated-networks-table";
+import { CreateNetworkForm } from "@/components/features/networks/create-network-form";
+import { useNetworks } from "@/core/hooks/useNetworks";
+
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+export default function NetworksPage() {
+ const { networks, isFetching, error } = useNetworks();
+
+ return (
+
+
+
+
+
Sensor Manufacturers
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx
new file mode 100644
index 0000000000..044cb0cc47
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import axios from "axios";
+import { useRouter } from "next/navigation";
+import NetworkRequestTable from "@/components/features/networks/request-table";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { NetworkCreationRequest } from "@/core/apis/networks";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBanner } from "@/context/banner-context";
+
+type TabStatus = "all" | "pending" | "under_review" | "approved" | "denied";
+
+interface NetworkRequestsClientProps {
+ initialRequests: NetworkCreationRequest[];
+}
+
+export default function NetworkRequestsClient({ initialRequests }: NetworkRequestsClientProps) {
+ const router = useRouter();
+ const [activeTab, setActiveTab] = useState("pending");
+ const [isUpdating, setIsUpdating] = useState(false);
+ const { showBanner } = useBanner();
+ // Action management
+ const [selectedRequest, setSelectedRequest] = useState(null);
+ const [actionType, setActionType] = useState<'approve' | 'deny' | 'review' | null>(null);
+ const [notes, setNotes] = useState("");
+
+ const handleRefresh = () => {
+ router.refresh();
+ };
+
+ const counts = useMemo(() => ({
+ all: initialRequests.length,
+ pending: initialRequests.filter(r => r.status === "pending").length,
+ under_review: initialRequests.filter(r => r.status === "under_review").length,
+ approved: initialRequests.filter(r => r.status === "approved").length,
+ denied: initialRequests.filter(r => r.status === "denied").length,
+ }), [initialRequests]);
+
+ const filteredRequests = useMemo(() => {
+ let filtered = initialRequests;
+
+ if (activeTab !== "all") {
+ filtered = filtered.filter(r => r.status === activeTab);
+ }
+
+ return filtered;
+ }, [initialRequests, activeTab]);
+
+ const handleActionRequest = (request: NetworkCreationRequest, type: 'approve' | 'deny' | 'review') => {
+ setSelectedRequest(request);
+ setActionType(type);
+ setNotes("");
+ };
+
+ const confirmAction = async () => {
+ if (!selectedRequest || !actionType) return;
+
+ setIsUpdating(true);
+ try {
+ const response = await axios.put(`/api/devices/network-creation-requests/${selectedRequest._id}/${actionType}`, {
+ reviewer_notes: notes
+ });
+
+ showBanner({
+ message: response.data.message || 'Status updated successfully',
+ severity: 'success',
+ scoped: false
+ });
+
+ setSelectedRequest(null);
+ setActionType(null);
+ router.refresh(); // Invalidate server data
+ } catch (error: unknown) {
+ showBanner({
+ message: `Action failed: ${getApiErrorMessage(error)}`,
+ severity: 'error',
+ scoped: false
+ });
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ return (
+
+
+ {/* Page Header */}
+
+
+
Sensor Manufacturer Requests
+
+ Manage and review new sensor requests across the platform.
+
+
+
+
+ {/* Filters & Tabs */}
+
+ setActiveTab(v as TabStatus)} className="w-fit">
+
+
+ Pending ({counts.pending})
+
+
+ In Review ({counts.under_review})
+
+
+ Approved ({counts.approved})
+
+
+ Denied ({counts.denied})
+
+
+ All ({counts.all})
+
+
+
+
+
+ Refresh
+
+
+
+ {/* Table Content */}
+
+
+
+
+ {/* Status Action Dialog */}
+
setSelectedRequest(null)}
+ title={`${actionType?.charAt(0).toUpperCase()}${actionType?.slice(1)} Request`}
+ size="md"
+ primaryAction={{
+ label: isUpdating ? "Processing..." : "Confirm",
+ onClick: confirmAction,
+ disabled: isUpdating
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => setSelectedRequest(null),
+ variant: "outline"
+ }}
+ >
+
+
+ You are about to {actionType} the request for {selectedRequest?.net_name} .
+
+
setNotes(e.target.value)}
+ rows={4}
+ />
+ {actionType === 'deny' && (
+
+ Note: Denial notes will be included in the email notification to the requester.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx
new file mode 100644
index 0000000000..1b9d5eb98a
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx
@@ -0,0 +1,39 @@
+import { getServerSession } from "next-auth/next";
+import { options } from "@/app/api/auth/[...nextauth]/options";
+import NetworkRequestsClient from "./NetworkRequestsClient";
+import { NetworkCreationRequest } from "@/core/apis/networks";
+import logger from "@/lib/logger";
+
+import { notFound } from "next/navigation";
+import { networkService } from "@/core/services/network-service";
+
+async function getNetworkRequests(): Promise {
+ try {
+ const session = await getServerSession(options);
+ const token = (session as { user?: { accessToken?: string } })?.user?.accessToken;
+
+ if (!token) {
+ return [];
+ }
+
+ const adminSecret = process.env.ADMIN_SECRET;
+ if (!adminSecret) {
+ logger.error("ADMIN_SECRET is not defined in environment variables");
+ return [];
+ }
+
+ return await networkService.getNetworkCreationRequests(token, adminSecret);
+ } catch (error) {
+ if (error instanceof Error && error.message === "NOT_FOUND") {
+ notFound();
+ }
+ logger.error(`Error fetching network requests on server: ${error}`);
+ return [];
+ }
+}
+
+export default async function NetworkRequestsPage() {
+ const requests = await getNetworkRequests();
+
+ return ;
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx
new file mode 100644
index 0000000000..f63017e579
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import React from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { useShippingBatchDetails, useGenerateShippingLabels } from '@/core/hooks/useDevices';
+import { format } from 'date-fns';
+import { ArrowLeft } from 'lucide-react';
+import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable';
+import { ShippingStatusDevice } from '@/app/types/devices';
+import { Skeleton } from "@/components/ui/skeleton";
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { AqArrowLeft } from '@airqo/icons-react';
+import { useBanner } from '@/context/banner-context';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+import ShippingLabelPrintModal from '@/components/features/shipping/ShippingLabelPrintModal';
+import { useQueryClient } from '@tanstack/react-query';
+import { useState, useCallback } from 'react';
+import { usePageTitle } from '@/context/page-title-context';
+
+type BatchDevice = ShippingStatusDevice & {
+ id: string | number;
+ createdAt?: string;
+ [key: string]: unknown;
+};
+
+const BatchDetailsPage = () => {
+ const params = useParams();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const batchId = params.batchId as string;
+ const { data, isLoading, error } = useShippingBatchDetails(batchId);
+ const { showBanner } = useBanner();
+ const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels({
+ onSuccess: (data) => {
+ if (data.success) {
+ showBanner({ severity: 'success', message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, scoped: false });
+ setShowLabelModal(true);
+ }
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Label Generation Failed: ${getApiErrorMessage(error)}`, scoped: false });
+ },
+ });
+ const [showLabelModal, setShowLabelModal] = useState(false);
+ usePageTitle({
+ title: data?.batch?.batch_name || "Shipping Batch",
+ section: "Shipping",
+ });
+
+ const handleGenerateLabels = useCallback((ids: (string | number)[]) => {
+ if (!ids || ids.length === 0) {
+ showBanner({ severity: 'error', message: 'Please select at least one device', scoped: false });
+ return;
+ }
+
+ const selectedDeviceNames = (data?.batch?.devices || [])
+ .filter(device => ids.includes(device._id || device.name))
+ .map(device => device.name)
+ .filter(name => name && name.trim().length > 0);
+
+ if (selectedDeviceNames.length === 0) {
+ showBanner({ severity: 'error', message: 'Selected devices have no valid names', scoped: false });
+ return;
+ }
+
+ generateLabels(selectedDeviceNames);
+ }, [generateLabels, data?.batch?.devices, showBanner]);
+
+ const handleGenerateAllLabels = useCallback(() => {
+ const allDeviceNames = (data?.batch?.devices || [])
+ .filter(device => device.claim_status !== 'claimed')
+ .map(device => device.name)
+ .filter(name => name && name.trim().length > 0);
+
+ if (allDeviceNames.length === 0) {
+ showBanner({ severity: 'error', message: 'No unclaimed devices found with valid names in this batch', scoped: false });
+ return;
+ }
+
+ generateLabels(allDeviceNames);
+ }, [generateLabels, data?.batch?.devices, showBanner]);
+
+ const handleCloseModal = useCallback(() => {
+ setShowLabelModal(false);
+ queryClient.invalidateQueries({ queryKey: ['shippingBatchDetails', batchId] });
+ }, [queryClient, batchId]);
+
+ const actions = [
+ {
+ label: isGenerating ? 'Generating...' : 'Generate Labels',
+ value: 'generate_labels',
+ handler: handleGenerateLabels
+ }
+ ];
+
+ const columns: TableColumn[] = [
+ {
+ key: 'name',
+ label: 'Device Name',
+ render: (value) => {value as string}
+ },
+ {
+ key: 'claim_token',
+ label: 'Claim Token',
+ render: (value) => {(value as string | null) || '-'}
+ },
+ {
+ key: 'claim_status',
+ label: 'Status',
+ render: (value) => (
+
+ {value ? (value as string).charAt(0).toUpperCase() + (value as string).slice(1) : 'Unknown'}
+
+ )
+ },
+ {
+ key: 'createdAt',
+ label: 'Created At',
+ render: (value) => {
+ const date = value ? new Date(value as string) : null;
+ return
+ {date && !isNaN(date.getTime()) ? format(date, 'MMM dd, yyyy HH:mm') : '-'}
+ ;
+ }
+ },
+ ];
+
+ if (!isLoading && (error || !data?.batch)) {
+ return (
+
+
+ Error loading batch details: {error?.message || 'Batch not found'}
+
+
router.back()}
+ className="mt-4 flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
+ >
+
+ Back to Shipping
+
+
+ );
+ }
+
+ const batch = data?.batch;
+
+ const tableData: BatchDevice[] = (batch?.devices || []).map(device => ({
+ ...device,
+ id: device._id || device.name
+ }));
+
+ return (
+
+ {/* Header */}
+
router.back()} Icon={AqArrowLeft}>
+ Back
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : (
+ <>
+
+ {batch?.batch_name || 'Unnamed Batch'}
+
+
+ Batch ID: {batch?._id}
+
+ >
+ )}
+
+ {!isLoading && (
+
+
+ {isGenerating ? 'Generating...' : 'Generate Labels'}
+
+
+ )}
+
+
+ {/* Devices Table */}
+
+ device.claim_status !== 'claimed'}
+ />
+
+
+ {/* Label Print Modal */}
+ {labelsData?.success && (
+
+ )}
+
+ );
+};
+
+export default BatchDetailsPage;
diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx
new file mode 100644
index 0000000000..cdfe3fcf5e
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import React, { useState } from 'react';
+import { useShippingStatus } from '@/core/hooks/useDevices';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { AqPlus } from '@airqo/icons-react';
+import { PrepareShippingModal } from '@/components/features/shipping/PrepareShippingModal';
+import ShippingBatchesTable from '@/components/features/shipping/ShippingBatchesTable';
+import { Skeleton } from "@/components/ui/skeleton";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+const ShippingPage = () => {
+ const [showPrepareModal, setShowPrepareModal] = useState(false);
+
+ return (
+
+
+
+
+
Device Shipping Management
+
Manage device shipping status and labels
+
+
setShowPrepareModal(true)}
+ Icon={AqPlus}
+ >
+ Prepare New Batch
+
+
+
+
+
+
+
+
+
+
+
+
setShowPrepareModal(false)}
+ />
+
+
+ );
+};
+
+const ShippingStatus = () => {
+ const { data: statusData, isLoading } = useShippingStatus();
+
+ return (
+
+ {isLoading ? (
+
+ {[...Array(4)].map((_, i) => (
+
+
+
+
+ ))}
+
+ ) : (
+ statusData?.shipping_status?.summary && (
+
+
+
{statusData.shipping_status.summary.total_devices}
+
Total Devices
+
+
+
{statusData.shipping_status.summary.prepared_for_shipping}
+
Prepared
+
+
+
{statusData.shipping_status.summary.claimed_devices}
+
Claimed
+
+
+
{statusData.shipping_status.summary.deployed_devices}
+
Deployed
+
+
+ )
+ )}
+
+ );
+};
+
+export default ShippingPage;
diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx
new file mode 100644
index 0000000000..d3f18bd909
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { SiteDevice } from "@/app/types/sites";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { CircuitBoard } from "lucide-react";
+
+interface SiteDevicesProps {
+ devices: SiteDevice[];
+}
+
+export function SiteDevices({ devices }: SiteDevicesProps) {
+ if (!devices.length) {
+ return (
+
+
+
+
No devices found
+
+ There are no devices deployed at this site yet.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Name
+ Description
+ Site
+ Is Primary
+ Is Co-located
+ Added On
+ Deployment status
+
+
+
+ {devices.map((device) => (
+
+ {device.name}
+ {device.description || "N/A"}
+ {device.site || "N/A"}
+
+
+ {device.isPrimary ? "Yes" : "No"}
+
+
+
+
+ {device.isCoLocated ? "Yes" : "No"}
+
+
+ {device.registrationDate}
+
+
+ {device.deploymentStatus}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx
new file mode 100644
index 0000000000..3f9dc28cbb
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import DeviceDetailsLayout from "@/components/features/devices/device-details-layout";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+export default function AdminSiteDeviceDetailsPage() {
+ const params = useParams();
+ const deviceId = params?.deviceId;
+
+ if (!deviceId || typeof deviceId !== 'string') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx
new file mode 100644
index 0000000000..a2393823d3
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { AqArrowLeft } from "@airqo/icons-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { useParams } from "next/navigation";
+import { RefreshCw } from "lucide-react";
+import { SiteInformationCard } from "@/components/features/sites/site-information-card";
+import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card";
+import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog";
+import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table";
+import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card";
+import SiteActivityCard from "@/components/features/sites/site-activity-card";
+import { usePageTitle } from "@/context/page-title-context";
+
+const ContentGridSkeleton = () => (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function SiteDetailsPage() {
+ const params = useParams();
+ const siteId = params.id as string;
+ const { data: site, isLoading, error } = useSiteDetails(siteId);
+ const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata();
+ const router = useRouter();
+ const [editSection, setEditSection] = useState<"general" | "mobile" | null>(
+ null
+ );
+ usePageTitle({ title: site?.name || "Site Details", section: "Sites" });
+
+ if (error) {
+ return (
+
+
+
+ Error
+ {error.message}
+
+
+ );
+ }
+
+ return (
+
+
+ router.back()}
+ Icon={AqArrowLeft}
+ >
+ Back
+
+
+ refreshMetadata(siteId)}
+ disabled={isRefreshing || isLoading || !site}
+ loading={isRefreshing}
+ Icon={RefreshCw}
+ className="text-xs font-medium"
+ >
+ Refresh Metadata
+
+
+
+ {isLoading ? (
+
+ ) : !site ? (
+
+ Site not found.
+
+ ) : (
+ <>
+
+
+ setEditSection("general")}
+ />
+
+
+
+ setEditSection("mobile")}
+ />
+
+
+
+
+
+
+
+
+ {
+ router.push(`/admin/sites/${siteId}/devices/${device._id}`);
+ }}
+ />
+
+
!open && setEditSection(null)}
+ site={site}
+ section={editSection || "general"}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/admin/sites/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx
new file mode 100644
index 0000000000..aa1df046ef
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import SitesTable from "@/components/features/sites/sites-list-table";
+import { SiteStatsCards } from "@/components/features/sites/site-stats-cards";
+import { usePageTitle } from "@/context/page-title-context";
+import dynamic from "next/dynamic";
+
+const CreateSiteForm = dynamic(() =>
+ import('@/components/features/sites/create-site-form').then(mod => mod.CreateSiteForm),
+ {
+ ssr: false,
+ loading: () =>
+ }
+);
+
+export default function SitesPage() {
+ usePageTitle({ title: "Sites", section: "Administrative Panel" });
+
+ return (
+
+
+
+
+
Sites
+
+ Manage and organize your monitoring sites
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx
new file mode 100644
index 0000000000..f34c2d053b
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { useEffect, useState, useMemo } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { AqArrowLeft } from "@airqo/icons-react";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { useCohortDetails } from "@/core/hooks/useCohorts";
+import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table";
+import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card";
+import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { usePageTitle } from "@/context/page-title-context";
+
+const ContentGridSkeleton = () => (
+
+ {[...Array(2)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function CohortDetailsPage() {
+ const router = useRouter();
+ const params = useParams();
+ const cohortId = typeof params?.id === "string" ? params.id : "";
+
+ const { data: cohort, isLoading, error } = useCohortDetails(cohortId, { enabled: !!cohortId });
+ usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" });
+
+ const [cohortDetails, setCohortDetails] = useState<{
+ name: string;
+ id: string;
+ visibility: boolean;
+ cohort_tags: string[];
+ }>({
+ name: "",
+ id: "",
+ visibility: true,
+ cohort_tags: [],
+ });
+
+ const [showDetailsModal, setShowDetailsModal] = useState(false);
+
+ useEffect(() => {
+ if (cohort) {
+ setCohortDetails({
+ name: cohort.name,
+ id: cohort._id,
+ visibility: cohort.visibility,
+ cohort_tags: cohort.cohort_tags || [],
+ });
+ }
+ }, [cohort]);
+
+ const devices = useMemo(() => cohort?.devices || [], [cohort]);
+
+ if (!cohortId) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ router.back()} Icon={AqArrowLeft}>
+ Back
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+
+ Error
+
+ {String((error as Error)?.message || "Unable to load cohort details")}
+
+
+ ) : (
+
+ {/* Top Cards: Details & API */}
+
+ setShowDetailsModal(true)}
+ loading={isLoading}
+ />
+
+
+
+ {/* Devices List */}
+
+
Cohort Devices
+
+
+
+
setShowDetailsModal(false)}
+ />
+
+ )}
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/page.tsx
new file mode 100644
index 0000000000..e5ba98aa4a
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/cohorts/page.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+import { format } from 'date-fns';
+import { Badge } from "@/components/ui/badge";
+import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable";
+import { useCohorts, useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts";
+import { Cohort } from "@/app/types/cohorts";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useAppSelector } from "@/core/redux/hooks";
+import CohortsEmptyState from "@/components/features/cohorts/cohorts-empty-state";
+
+type CohortRow = {
+ id: string;
+ name: string;
+ numberOfDevices: number;
+ visibility: boolean;
+ dateCreated?: string;
+}
+
+export default function CohortsPage() {
+ const router = useRouter();
+ const { data: session } = useSession();
+ const user = useAppSelector((state) => state.user.userDetails);
+
+ const {
+ pagination, setPagination,
+ searchTerm, setSearchTerm,
+ sorting, setSorting
+ } = useServerSideTableState({ initialPageSize: 25 });
+
+ const { isExternalOrg, activeGroup, userScope } = useUserContext();
+
+ const isPersonalScope = userScope === 'personal';
+ const userId = (session?.user as { id?: string })?.id || user?._id;
+
+ // --- Personal scope: use personal user cohorts API ---
+ const {
+ data: personalCohortIds = [],
+ isLoading: isLoadingPersonalCohorts,
+ } = usePersonalUserCohorts(userId, {
+ enabled: isPersonalScope && !!userId,
+ });
+
+ // --- Org scope: use group cohorts ---
+ const {
+ data: groupCohortIds,
+ isLoading: isLoadingGroupCohorts,
+ } = useGroupCohorts(activeGroup?._id, {
+ enabled: isExternalOrg && !!activeGroup?._id && !isPersonalScope,
+ });
+
+ // Resolve effective cohort IDs based on scope
+ const targetCohortIds = useMemo(() => {
+ if (isPersonalScope) return personalCohortIds;
+ if (isExternalOrg) return groupCohortIds || [];
+ return [];
+ }, [isPersonalScope, personalCohortIds, isExternalOrg, groupCohortIds]);
+
+ const isResolvingIds =
+ (isPersonalScope && isLoadingPersonalCohorts) ||
+ (isExternalOrg && !isPersonalScope && isLoadingGroupCohorts);
+
+ const hasIdsToFetch = targetCohortIds.length > 0;
+ const shouldFetchCohorts = hasIdsToFetch && !isResolvingIds;
+
+ const { cohorts, meta, isFetching: isFetchingCohorts, error } = useCohorts(
+ {
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ cohort_id: hasIdsToFetch ? targetCohortIds : undefined,
+ },
+ { enabled: shouldFetchCohorts }
+ );
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ const tableLoading = isResolvingIds || (hasIdsToFetch && isFetchingCohorts);
+ const displayError = (!hasIdsToFetch && !tableLoading) ? null : error;
+ const showEmptyState =
+ !tableLoading &&
+ !displayError &&
+ (!hasIdsToFetch || (cohorts && cohorts.length === 0));
+
+ const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({
+ ...c,
+ id: c._id,
+ dateCreated: c.createdAt,
+ })), [cohorts]);
+
+ const columns: TableColumn[] = [
+ {
+ key: "name",
+ label: "Cohort Name",
+ sortable: true,
+ render: (v) => v ?? "-"
+ },
+ {
+ key: "numberOfDevices",
+ label: "Number of devices",
+ sortable: true,
+ render: (v) => (v ?? 0)
+ },
+ {
+ key: "visibility",
+ label: "Visibility",
+ sortable: true,
+ render: (v) => (
+ {v ? "Public" : "Private"}
+ )
+ },
+ {
+ key: "dateCreated",
+ label: "Date created",
+ sortable: true,
+ render: (value) => {
+ if (!value) return "-";
+ const date = new Date(value as string);
+ if (isNaN(date.getTime())) return "-";
+ return format(date, "MMM d yyyy, h:mm a");
+ }
+ }
+ ];
+
+ if (showEmptyState) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Cohorts
+
+ Cohorts are groups of devices claimed by you or your organization. Use them to control data privacy settings and determine whether your device data is public or private.
+
+
+
+
+ {
+ const row = item as CohortRow;
+ if (row?.id) router.push(`/cohorts/${row.id}`);
+ }}
+ emptyState={displayError ? (displayError.message || "Unable to load cohorts") : "No cohorts available"}
+ serverSidePagination
+ pageCount={pageCount}
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ onSearchChange={setSearchTerm}
+ searchTerm={searchTerm}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ searchable
+ />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx
new file mode 100644
index 0000000000..d8688e08ab
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+import React, { useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { AqCollocation, AqPlus } from "@airqo/icons-react";
+import { Upload } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import { useMyDevices, useDevices } from "@/core/hooks/useDevices";
+import { useAppSelector } from "@/core/redux/hooks";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { DeviceAssignmentModal } from "@/components/features/devices/device-assignment-modal";
+import ImportDeviceModal from "@/components/features/devices/import-device-modal";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import dynamic from "next/dynamic";
+
+const ClaimDeviceModal = dynamic(
+ () => import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
+import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table";
+
+import { OrphanedDevicesAlert } from "@/components/features/devices/orphaned-devices-alert";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+const MyDevicesPage = () => {
+ const { userDetails, activeGroup } = useAppSelector((state) => state.user);
+ const [showAssignmentModal, setShowAssignmentModal] = useState(false);
+ const [isImportDeviceOpen, setImportDeviceOpen] = useState(false);
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
+
+ const { userScope } = useUserContext();
+
+ const {
+ data: myDevicesData,
+ isLoading: isLoadingMyDevices,
+ error: myDevicesError,
+ } = useMyDevices(userDetails?._id || "", activeGroup?._id, {
+ enabled: userScope === 'personal',
+ });
+
+ const {
+ devices: orgDevices,
+ isLoading: isLoadingOrgDevices,
+ error: orgDevicesError,
+ } = useDevices({
+ enabled: userScope === 'organisation',
+ });
+
+ const devices = React.useMemo(() => {
+ return userScope === 'personal'
+ ? myDevicesData?.devices || []
+ : orgDevices;
+ }, [userScope, myDevicesData?.devices, orgDevices]);
+ const isLoading = userScope === 'personal' ? isLoadingMyDevices : isLoadingOrgDevices;
+ const error = userScope === 'personal' ? myDevicesError : orgDevicesError;
+ const searchParams = useSearchParams();
+ const rawStatus = searchParams.get("status");
+ const statusFilter = ["operational", "transmitting", "not_transmitting", "data_available"].includes(rawStatus || "")
+ ? rawStatus
+ : null;
+
+ const filteredDevices = React.useMemo(() => {
+ if (!devices) return [];
+ if (!statusFilter) return devices;
+
+ return devices.filter((device) => {
+ if (statusFilter === "operational") {
+ return device.rawOnlineStatus === true && device.isOnline === true;
+ }
+
+ if (statusFilter === "transmitting") {
+ return device.rawOnlineStatus === true && device.isOnline === false;
+ }
+
+ if (statusFilter === "not_transmitting") {
+ return device.rawOnlineStatus === false && device.isOnline === false;
+ }
+
+ if (statusFilter === "data_available") {
+ return device.rawOnlineStatus === false && device.isOnline === true;
+ }
+
+ return true;
+ });
+ }, [devices, statusFilter]);
+
+ if (error) {
+ return (
+
+
+ {/* Header */}
+
+
+
My Devices
+
+ Manage your personal and shared devices
+ {activeGroup && (
+
+ • Viewing in {activeGroup.grp_title}
+
+ )}
+
+
+
+ setIsClaimModalOpen(true)}
+ Icon={AqPlus}
+ permission={PERMISSIONS.DEVICE.CLAIM}
+ >
+ Add AirQo Device
+
+ setImportDeviceOpen(true)}
+ Icon={Upload}
+ >
+ Import External Device
+
+
+
+
+ {/* Empty State */}
+
+
+
+
+
+ Unable to load devices
+
+
+ There was an error loading your devices. Please try again or
+ contact support if the problem persists.
+
+
+ window.location.reload()}>
+ Retry
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
My Devices
+ {statusFilter && (
+
+ Filtered: {statusFilter.replace("_", " ")}
+
+ )}
+
+
+ Manage your personal and shared devices
+
+
+
+
setIsClaimModalOpen(true)}
+ disabled={isLoading}
+ Icon={AqPlus}
+ permission={PERMISSIONS.DEVICE.CLAIM}
+ >
+ Add AirQo Device
+
+
setImportDeviceOpen(true)}
+ disabled={isLoading}
+ Icon={Upload}
+ >
+ Import External Device
+
+ {/*
+
+
+
+
+
+
+ setShowAssignmentModal(true)}>
+ Share Device
+
+
+ */}
+
+
+
+ {userDetails?._id &&
}
+
+
+
+ {/* Modals */}
+
+
setIsClaimModalOpen(false)}
+ />
+ {
+ setShowAssignmentModal(false);
+ }}
+ onSuccess={() => {
+ setShowAssignmentModal(false);
+ }}
+ />
+
+
+ );
+};
+
+export default MyDevicesPage;
diff --git a/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx
new file mode 100644
index 0000000000..53c78217ba
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import DeviceDetailsLayout from "@/components/features/devices/device-details-layout";
+
+export default function DeviceDetailsPage() {
+ const params = useParams();
+ const deviceId = params?.id as string;
+
+ return ;
+}
diff --git a/src/vertex-template/app/(authenticated)/devices/overview/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx
new file mode 100644
index 0000000000..182dcc69c2
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, Upload } from "lucide-react";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { usePermission } from "@/core/hooks/usePermissions";
+import ImportDeviceModal from "@/components/features/devices/import-device-modal";
+import DevicesTable from "@/components/features/devices/device-list-table";
+import dynamic from "next/dynamic";
+
+const ClaimDeviceModal = dynamic(
+ () => import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+export default function DevicesPage() {
+ const [isImportDeviceOpen, setImportDeviceOpen] = useState(false);
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
+
+ // Permission checks
+ const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE);
+
+ return (
+
+
+
+
+
Devices
+
+ Manage and organize your devices.
+
+
+
+
+ setIsClaimModalOpen(true)}
+ Icon={Plus}
+ permission={PERMISSIONS.DEVICE.CLAIM}
+ >
+ Add AirQo Device
+
+
+ setImportDeviceOpen(true)}
+ Icon={Upload}
+ permission={PERMISSIONS.DEVICE.CLAIM}
+ >
+ Import External Device
+
+
+
+
+ {/* Modal Components */}
+
+
setIsClaimModalOpen(false)}
+ />
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/home/page.tsx b/src/vertex-template/app/(authenticated)/home/page.tsx
new file mode 100644
index 0000000000..fa0bf88607
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/home/page.tsx
@@ -0,0 +1,694 @@
+"use client";
+
+import React from "react";
+import dynamic from "next/dynamic";
+import { useSession } from "next-auth/react";
+import { Plus, Upload, AlertTriangle } from "lucide-react";
+import { useAppSelector, useAppDispatch } from "@/core/redux/hooks";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { usePermissions } from "@/core/hooks/usePermissions";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDevices, useMyDevices } from "@/core/hooks/useDevices";
+import { useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts";
+import { useQueryClient } from "@tanstack/react-query";
+import ContextHeader from "@/components/features/home/context-header";
+import NetworkVisibilityCard from "@/components/features/home/network-visibility-card";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import OnboardingChecklist from "@/components/features/home/onboarding-checklist";
+import { cn } from "@/lib/utils";
+import { Device } from "@/app/types/devices";
+import { Group } from "@/app/types/groups";
+import { useGroupDetails, useUpdateGroupOnboarding } from "@/core/hooks/useGroups";
+import { updateActiveGroupOnboarding } from "@/core/redux/slices/userSlice";
+import { formatTitle } from "@/components/features/org-picker/organization-picker";
+import ReusableToast from "@/components/shared/toast/ReusableToast";
+import logger from "@/lib/logger";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+// ─── Checklist localStorage helpers ──────────────────────────────────────────
+// Keyed per org/user so state is independent across workspace switches.
+
+function getChecklistKey(orgId: string) {
+ return `vertex_onboarding_${orgId}`;
+}
+
+function getChecklistState(orgId: string): {
+ completedSteps: string[];
+ dismissed: boolean;
+} {
+ if (typeof window === "undefined") return { completedSteps: [], dismissed: false };
+ try {
+ const raw = window.localStorage.getItem(getChecklistKey(orgId));
+ return raw ? JSON.parse(raw) : { completedSteps: [], dismissed: false };
+ } catch {
+ return { completedSteps: [], dismissed: false };
+ }
+}
+
+function saveChecklistState(
+ orgId: string,
+ state: { completedSteps: string[]; dismissed: boolean }
+) {
+ try {
+ window.localStorage.setItem(getChecklistKey(orgId), JSON.stringify(state));
+ } catch {
+ // localStorage unavailable — fail silently
+ }
+}
+
+// ─── Skeletons ────────────────────────────────────────────────────────────────
+
+const StatsSkeleton = () => (
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+);
+
+// ─── Lazy-loaded heavy components ─────────────────────────────────────────────
+
+const DashboardStatsCards = dynamic(
+ () => import("@/components/features/dashboard/stats-cards").then((mod) => ({
+ default: mod.DashboardStatsCards,
+ })),
+ { ssr: false, loading: () => }
+);
+
+const ClaimDeviceModal = dynamic(
+ () => import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
+
+const ImportDeviceModal = dynamic(
+ () => import("@/components/features/devices/import-device-modal"),
+ { ssr: false }
+);
+
+const AssignCohortDevicesDialog = dynamic(
+ () => import("@/components/features/cohorts/assign-cohort-devices").then(mod => ({ default: mod.AssignCohortDevicesDialog })),
+ { ssr: false }
+);
+
+const LoginFeedbackToast = dynamic(
+ () => import("@/components/features/feedback/login-feedback-toast"),
+ { ssr: false }
+);
+
+// ─── Page component ───────────────────────────────────────────────────────────
+
+const WelcomePage = () => {
+ const { data: session } = useSession();
+ const {
+ userContext,
+ userScope,
+ hasError,
+ error,
+ isLoading: isLoadingUserContext,
+ } = useUserContext();
+
+ const [isClaimModalOpen, setIsClaimModalOpen] = React.useState(false);
+ const [isImportModalOpen, setIsImportModalOpen] = React.useState(false);
+ const [justCompletedClaim, setJustCompletedClaim] = React.useState(false);
+ const [isAddDeviceChoiceOpen, setIsAddDeviceChoiceOpen] = React.useState(false);
+ const [isAssignCohortModalOpen, setIsAssignCohortModalOpen] = React.useState(false);
+ const [newlyClaimedDevice, setNewlyClaimedDevice] = React.useState[] | undefined
+ >();
+ const [accordionItems, setAccordionItems] = React.useState(["stats", "visibility"]);
+ const [highlightVisibility, setHighlightVisibility] = React.useState(false);
+ const visibilityRef = React.useRef(null);
+ const queryClient = useQueryClient();
+ const dispatch = useAppDispatch();
+ const isMounted = React.useRef(true);
+
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ const user = useAppSelector((state) => state.user.userDetails);
+ const userId = (session?.user as { id?: string })?.id || user?._id;
+
+ // Stable key per workspace — personal users get their own key, org users get their
+ // actual group ID so switching Kampala ↔ Wakiso gives independent checklist state.
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+ const orgId = userContext === "personal"
+ ? `personal_${userId}`
+ : activeGroup?._id
+ ? `org_${activeGroup._id}`
+ : null;
+
+ const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useGroupDetails(activeGroup?._id as string, {
+ enabled: userScope === "organisation" && !!activeGroup?._id,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const groupDetails = groupDetailsData?.group;
+
+ const [localChecklistState, setLocalChecklistState] = React.useState(() =>
+ getChecklistState(orgId ?? "")
+ );
+
+ React.useEffect(() => {
+ if (orgId && userScope === "personal") {
+ setLocalChecklistState(getChecklistState(orgId));
+ }
+ }, [orgId, userScope]);
+
+ const activeChecklistState = React.useMemo(() => {
+ if (userScope === "organisation") {
+ const checklistSrc = groupDetails?.onboarding_checklist || activeGroup?.onboarding_checklist;
+ return {
+ completedSteps: checklistSrc?.completed_steps || [],
+ dismissed: checklistSrc?.is_dismissed || false,
+ };
+ }
+ return localChecklistState;
+ }, [userScope, activeGroup?.onboarding_checklist, groupDetails?.onboarding_checklist, localChecklistState]);
+
+ // ── Permissions ────────────────────────────────────────────────────────────
+ const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE];
+ const permissionsMap = usePermissions(permissionsToCheck);
+ const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE];
+
+ // ── Device data ────────────────────────────────────────────────────────────
+ const { devices: groupDevices, isLoading: isLoadingGroupDevices } = useDevices({
+ limit: 1,
+ enabled: userScope === "organisation",
+ });
+
+ const { data: myDevicesData, isLoading: isLoadingMyDevices } = useMyDevices(
+ userId || "",
+ undefined,
+ { enabled: !!userId && userScope === "personal" }
+ );
+
+ // Cohort data — used to auto-detect step 2 completion
+ const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, {
+ enabled: userScope === "organisation" && !!activeGroup?._id,
+ });
+ const { data: personalCohortIds } = usePersonalUserCohorts(userId, {
+ enabled: !!userId && userScope === "personal",
+ });
+
+ // ── Auto-sync step completion from real data ─────────────────────────────
+ // This ensures that if an org already has devices/cohorts when a user first
+ // logs in, steps 1 and 2 are immediately shown as complete.
+ const autoSteps = React.useMemo(() => {
+ if (!orgId) return [];
+
+ const hasDevices =
+ userScope === "personal"
+ ? (myDevicesData?.devices ?? []).length > 0
+ : groupDevices.length > 0;
+
+ const hasCohorts =
+ userScope === "personal"
+ ? (personalCohortIds ?? []).length > 0
+ : (groupCohortIds ?? []).length > 0;
+
+ const steps: string[] = [];
+ if (hasDevices) {
+ steps.push("add-device", "assign-cohort");
+ } else if (hasCohorts) {
+ steps.push("assign-cohort");
+ }
+ return steps;
+ }, [orgId, userScope, myDevicesData?.devices, groupDevices.length, personalCohortIds, groupCohortIds]);
+
+ const visuallyCompletedSteps = React.useMemo(() => {
+ return Array.from(new Set([...(activeChecklistState.completedSteps || []), ...autoSteps]));
+ }, [activeChecklistState.completedSteps, autoSteps]);
+
+ React.useEffect(() => {
+ if (autoSteps.length === 0) return;
+
+ if (userScope === "personal") {
+ setLocalChecklistState((prev) => {
+ const merged = Array.from(new Set([...(prev.completedSteps || []), ...autoSteps]));
+ // Only save/re-render if something actually changed
+ if (merged.length === (prev.completedSteps || []).length) return prev;
+ const next = { ...prev, completedSteps: merged };
+ saveChecklistState(orgId as string, next);
+ return next;
+ });
+ }
+ }, [orgId, userScope, autoSteps]);
+
+ const { mutateAsync: updateGroupOnboarding } = useUpdateGroupOnboarding();
+
+ const updateChecklist = React.useCallback(
+ async (patch: { action?: 'mark_step_complete' | 'dismiss_checklist', step_id?: string, completedSteps?: string[], dismissed?: boolean }) => {
+ if (userScope === "personal") {
+ setLocalChecklistState((prev) => {
+ const next = { ...prev };
+ if (patch.completedSteps) next.completedSteps = patch.completedSteps;
+ if (patch.dismissed !== undefined) next.dismissed = patch.dismissed;
+ if (orgId) saveChecklistState(orgId, next);
+ return next;
+ });
+ return;
+ }
+
+ // Handle Organisation Scope
+ if (userScope === "organisation" && activeGroup?._id) {
+ if (!patch.action) {
+ if (patch.dismissed) patch.action = 'dismiss_checklist';
+ else if (patch.completedSteps) {
+ const newestStep = patch.completedSteps[patch.completedSteps.length - 1];
+ if (newestStep) {
+ patch.action = 'mark_step_complete';
+ patch.step_id = newestStep;
+ }
+ }
+ }
+
+ if (patch.action) {
+ try {
+ const missingAutoSteps = autoSteps.filter(step =>
+ !activeChecklistState.completedSteps.includes(step) &&
+ !(patch.action === 'mark_step_complete' && patch.step_id === step)
+ );
+
+ if (missingAutoSteps.length > 0) {
+ for (const step of missingAutoSteps) {
+ try {
+ await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: 'mark_step_complete', step_id: step } });
+ } catch (e) {
+ logger.error("Failed to sync auto-step:", { error: getApiErrorMessage(e) });
+ }
+ }
+ }
+
+ const res = await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: patch.action, step_id: patch.step_id } });
+ const updatedChecklist = res.data?.onboarding_checklist || res.group?.onboarding_checklist;
+
+ if (res.success && updatedChecklist) {
+ dispatch(updateActiveGroupOnboarding(updatedChecklist));
+ queryClient.setQueryData(['groupDetails', activeGroup._id], (old: { group?: Group } | undefined) => {
+ if (!old || !old.group) return old;
+ return {
+ ...old,
+ group: {
+ ...old.group,
+ onboarding_checklist: updatedChecklist,
+ }
+ };
+ });
+ }
+ } catch (error) {
+ logger.error("Failed to update onboarding checklist:", { error: getApiErrorMessage(error) });
+ }
+ }
+ }
+ },
+ [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient, updateGroupOnboarding]
+ );
+
+ const openAddDeviceChoice = React.useCallback(() => {
+ setIsAddDeviceChoiceOpen(true);
+ }, []);
+
+ const handleGoToVisibility = React.useCallback(() => {
+ setAccordionItems(prev =>
+ prev.includes("visibility") ? prev : [...prev, "visibility"]
+ );
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ visibilityRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
+ setHighlightVisibility(true);
+ setTimeout(() => setHighlightVisibility(false), 4000); // longer — matches coach mark
+ });
+ });
+ }, []);
+
+ const openClaimModal = React.useCallback(() => {
+ if (justCompletedClaim) return;
+ setIsAddDeviceChoiceOpen(false);
+ setIsClaimModalOpen(true);
+ }, [justCompletedClaim]);
+
+ const openImportModal = React.useCallback(() => {
+ setIsAddDeviceChoiceOpen(false);
+ setIsImportModalOpen(true);
+ }, []);
+
+ const refreshHomeData = React.useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ["devices"] });
+ queryClient.invalidateQueries({ queryKey: ["myDevices"] });
+ queryClient.invalidateQueries({ queryKey: ["groupCohorts"] });
+ queryClient.invalidateQueries({ queryKey: ["personalUserCohorts"] });
+ queryClient.invalidateQueries({ queryKey: ["cohorts"] });
+ queryClient.invalidateQueries({ queryKey: ["deviceCount"] });
+ }, [queryClient]);
+
+ const handleDeviceAdded = React.useCallback(
+ (deviceInfo?: { deviceId?: string; deviceName?: string; cohortId?: string; isCohortImport?: boolean }) => {
+ setJustCompletedClaim(true);
+ setTimeout(() => setJustCompletedClaim(false), 1000);
+ refreshHomeData();
+
+ if (deviceInfo?.isCohortImport || deviceInfo?.cohortId) {
+ setNewlyClaimedDevice(undefined);
+ updateChecklist({
+ action: 'mark_step_complete',
+ step_id: 'add-device',
+ completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device", "assign-cohort"])),
+ });
+ } else {
+ if (deviceInfo?.deviceId) {
+ setNewlyClaimedDevice([{ _id: deviceInfo.deviceId, name: deviceInfo.deviceName || "", long_name: deviceInfo.deviceName || "" }]);
+ }
+ updateChecklist({
+ action: 'mark_step_complete',
+ step_id: 'add-device',
+ completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device"])),
+ });
+ }
+ },
+ [activeChecklistState.completedSteps, refreshHomeData, updateChecklist]
+ );
+
+ const handleCohortAssigned = React.useCallback(() => {
+ updateChecklist({
+ action: 'mark_step_complete',
+ step_id: 'assign-cohort',
+ completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "assign-cohort"])),
+ });
+ setNewlyClaimedDevice(undefined);
+ }, [activeChecklistState.completedSteps, updateChecklist]);
+
+
+
+
+ // ── Early returns ──────────────────────────────────────────────────────────
+
+ if (hasError) {
+ return (
+
+
+
+ {error || "Failed to load dashboard context."}
+
+
+ );
+ }
+
+ // ── Derived state ──────────────────────────────────────────────────────────
+
+ const hasNoDevices =
+ userScope === "personal"
+ ? (myDevicesData?.devices ?? []).length === 0
+ : groupDevices.length === 0;
+
+ const TOTAL_STEPS = 3; // add-device, assign-cohort, set-visibility
+
+ // The checklist stays visible as long as:
+ // 1. Not all steps are complete, AND
+ // 2. The user hasn't explicitly dismissed it
+ const allStepsComplete = visuallyCompletedSteps.length >= TOTAL_STEPS;
+ const isLoadingGroupDetailsSafe = userScope === "organisation" && isLoadingGroupDetails;
+ const showChecklist = !allStepsComplete && !activeChecklistState.dismissed && !isLoadingGroupDetailsSafe;
+
+ const showClaimDevice = (() => {
+ switch (userContext) {
+ case "personal":
+ case "external-org":
+ default:
+ return true;
+ }
+ })();
+
+ const renderSharedModals = () => (
+ <>
+ {
+ setIsAssignCohortModalOpen(open);
+ if (!open) setNewlyClaimedDevice(undefined);
+ }}
+ selectedDevices={newlyClaimedDevice as Device[]}
+ onSuccess={handleCohortAssigned}
+ title="Group your devices"
+ />
+
+ setIsClaimModalOpen(false)}
+ onSuccess={handleDeviceAdded}
+ mode={showChecklist ? "guided" : "fast"}
+ />
+ setIsImportModalOpen(open)}
+ onSuccess={handleDeviceAdded}
+ mode={showChecklist ? "guided" : "fast"}
+ />
+ setIsAddDeviceChoiceOpen(false)}
+ title="Add a device"
+ showFooter={false}
+ size="xl"
+ >
+
+
+
+
+
+ Add AirQo Device
+
+
+ Have an AirQo sensor? Easily add it to your workspace
+
+
+
+
+
+
+
+
+
+
+ Import Different Sensor Manufacturer
+
+
+ Import a device from a different manufacturer using a CSV
+ template.
+
+
+
+
+
+ >
+ );
+
+ const isLoading =
+ (userScope === "personal" && isLoadingMyDevices) ||
+ (userScope === "organisation" && isLoadingGroupDevices) ||
+ isLoadingUserContext;
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ {renderSharedModals()}
+ >
+ );
+ }
+
+ const renderMainContent = () => {
+ if (hasNoDevices) {
+ return (
+
+
+ {showChecklist && (
+ updateChecklist({ action: 'dismiss_checklist', dismissed: true })}
+ onAddDevice={openAddDeviceChoice}
+ onGoToCohorts={() => setIsAssignCohortModalOpen(true)}
+ onGoToVisibility={handleGoToVisibility}
+ onMarkAsDone={() => {}}
+ organizationName={formatTitle(activeGroup?.grp_title || "")}
+ isReadOnly={!canClaimDevice}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+ {showChecklist && (
+
updateChecklist({ action: 'dismiss_checklist', dismissed: true })}
+ onAddDevice={openAddDeviceChoice}
+ onGoToCohorts={() => setIsAssignCohortModalOpen(true)}
+ onGoToVisibility={handleGoToVisibility}
+ onMarkAsDone={() => {}}
+ organizationName={formatTitle(activeGroup?.grp_title || "")}
+ isReadOnly={!canClaimDevice}
+ />
+ )}
+
+ {showClaimDevice && (
+
+
Home
+
+ setIsClaimModalOpen(true)}
+ Icon={Plus}
+ >
+ Add AirQo Device
+
+ setIsImportModalOpen(true)}
+ Icon={Upload}
+ >
+ Import External Device
+
+
+
+ )}
+
+
+
+
+
+ Device Health
+
+
+
+
+
+
+
+
+ Device Visibility
+
+
+ {
+ const nextCompletedSteps = Array.from(
+ new Set([...visuallyCompletedSteps, "set-visibility"])
+ );
+ const justCompletedSetup =
+ !visuallyCompletedSteps.includes("set-visibility") &&
+ nextCompletedSteps.length >= TOTAL_STEPS;
+
+ updateChecklist({
+ action: 'mark_step_complete',
+ step_id: 'set-visibility',
+ completedSteps: nextCompletedSteps,
+ });
+
+ if (justCompletedSetup) {
+ ReusableToast({
+ message: "Workspace setup complete. You're ready to monitor and manage your devices.",
+ type: "SUCCESS",
+ });
+ setTimeout(() => {
+ if (isMounted.current) {
+ updateChecklist({ action: 'dismiss_checklist', dismissed: true });
+ }
+ }, 2000);
+ }
+ }}
+ />
+
+
+
+
+
+ {userId && (user?.email || user?.userName) && (
+
+ )}
+
+ );
+ };
+
+ return (
+ <>
+ {renderMainContent()}
+ {renderSharedModals()}
+ >
+ );
+};
+
+export default WelcomePage;
diff --git a/src/vertex-template/app/(authenticated)/layout.tsx b/src/vertex-template/app/(authenticated)/layout.tsx
new file mode 100644
index 0000000000..34d3c5f3c6
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/layout.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import Layout from "@/components/layout/layout";
+import { ForbiddenGuard } from "@/components/layout/accessConfig/forbidden-guard";
+import { useForbiddenHandler } from "@/core/hooks/useForbiddenHandler";
+import { useContextAwareRouting } from "@/core/hooks/useContextAwareRouting";
+import { PageTitleProvider } from "@/context/page-title-context";
+
+export default function AuthenticatedLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ // Listen for forbidden events
+ useForbiddenHandler();
+
+ // Handle context-aware routing
+ useContextAwareRouting();
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx
new file mode 100644
index 0000000000..39bea20b74
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { AqArrowLeft } from "@airqo/icons-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { useParams } from "next/navigation";
+import { RefreshCw } from "lucide-react";
+import { SiteInformationCard } from "@/components/features/sites/site-information-card";
+import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card";
+import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog";
+import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table";
+import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card";
+import SiteActivityCard from "@/components/features/sites/site-activity-card";
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { usePageTitle } from "@/context/page-title-context";
+
+const ContentGridSkeleton = () => (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+);
+
+export default function UserSiteDetailsPage() {
+ const params = useParams();
+ const siteId = params.id as string;
+ const { data: site, isLoading, error } = useSiteDetails(siteId);
+ const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata();
+ const router = useRouter();
+ const [editSection, setEditSection] = useState<"general" | "mobile" | null>(
+ null
+ );
+ usePageTitle({ title: site?.name || "Site Details", section: "Sites" });
+
+ return (
+
+
+
+ router.back()}
+ Icon={AqArrowLeft}
+ >
+ Back
+
+
+ refreshMetadata(siteId)}
+ disabled={isRefreshing || isLoading || !site}
+ loading={isRefreshing}
+ Icon={RefreshCw}
+ className="text-xs font-medium"
+ >
+ Refresh Metadata
+
+
+
+ {error ? (
+
+
+
+ Error
+
+ Unable to load site details. Please try again.
+
+
+
+ ) : isLoading ? (
+
+ ) : !site ? (
+
+ Site not found.
+
+ ) : (
+ <>
+
+
+ setEditSection("general")}
+ />
+
+
+
+ setEditSection("mobile")}
+ />
+
+
+
+
+
+
+
+
+ {
+ router.push(`/devices/overview/${device._id}`);
+ }}
+ />
+
+
!open && setEditSection(null)}
+ site={site}
+ section={editSection || "general"}
+ />
+ >
+ )}
+
+
+ );
+}
diff --git a/src/vertex-template/app/(authenticated)/sites/overview/page.tsx b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx
new file mode 100644
index 0000000000..121b18edcf
--- /dev/null
+++ b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { RouteGuard } from "@/components/layout/accessConfig/route-guard";
+import SitesTable from "@/components/features/sites/sites-list-table";
+import { PERMISSIONS } from "@/core/permissions/constants";
+
+export default function SitesOverviewPage() {
+ return (
+
+
+
+
+
Sites
+
+ Manage and organize your monitoring sites
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md
new file mode 100644
index 0000000000..551b609522
--- /dev/null
+++ b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md
@@ -0,0 +1,193 @@
+> **NOTE**: This document describes the specific solution for Private Context permissions. For the complete system architecture regarding Scopes and Contexts, please refer to [ACCESS-CONTROL-ARCHITECTURE.md](../internal/ACCESS-CONTROL-ARCHITECTURE.md).
+
+# Using AirQo Group Permissions in Private Context
+
+## Problem Statement
+Every user has an AirQo group assigned to them. The app has different contexts (personal, airqo-internal, external-org) with different view states. However, in the **private context**, we needed to use the permissions assigned to the user's AirQo group for certain use cases, instead of hardcoding or skipping permission checks.
+
+## Solution Overview
+
+We implemented a fallback mechanism that:
+1. Checks if the user is in `personal` context
+2. Retrieves their AirQo group (if they have one)
+3. Uses that group's permissions for permission checks
+
+## Implementation Details
+
+### 1. Permission Service (`core/permissions/permissionService.ts`)
+
+Added a new method to retrieve the user's AirQo group:
+
+```typescript
+/**
+ * Get user's AirQo group (if they have one)
+ * This is useful for private context permission checks
+ */
+getAirQoGroup(user: UserDetails): Group | undefined {
+ if (!user.groups) return undefined;
+
+ return user.groups.find((group) =>
+ group.grp_title.toLowerCase() === 'airqo'
+ );
+}
+```
+
+This method:
+- Searches through the user's groups
+- Finds the group with title "airqo" (case-insensitive)
+- Returns the group object or undefined if not found
+
+### 2. usePermission Hook (`core/hooks/usePermissions.ts`)
+
+Modified to use AirQo group permissions as fallback in personal context:
+
+```typescript
+export const usePermission = (permission: Permission, context?: Partial) => {
+ const user = useAppSelector((state) => state.user.userDetails);
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+ const activeNetwork = useAppSelector((state) => state.user.activeNetwork);
+ const userContext = useAppSelector((state) => state.user.userContext);
+
+ const result = useMemo(() => {
+ if (MOCK_PERMISSIONS_ENABLED) {
+ return MOCK_PERMISSIONS[permission] ?? false;
+ }
+
+ if (!user) return false;
+
+ // If in personal context and no active organization is provided,
+ // check against AirQo group permissions (if user has one)
+ let effectiveContext = context;
+ if (userContext === 'personal' && !context?.activeOrganization && !activeGroup) {
+ const airqoGroup = permissionService.getAirQoGroup(user);
+ if (airqoGroup) {
+ effectiveContext = {
+ ...context,
+ activeOrganization: airqoGroup,
+ };
+ }
+ }
+
+ return permissionService.hasPermission(user, permission, {
+ ...effectiveContext, // ✅ Spread first
+ activeOrganization: effectiveContext?.activeOrganization ?? activeGroup ?? undefined,
+ activeNetwork: effectiveContext?.activeNetwork ?? activeNetwork ?? undefined,
+ });
+ }, [user, permission, activeGroup, activeNetwork, userContext, context]);
+
+ return result;
+};
+```
+
+Key changes:
+- Added `userContext` to the hook's dependencies
+- Check if we're in `personal` context without an active group
+- If so, retrieve the AirQo group and use it as the `activeOrganization`
+- This ensures permission checks use the AirQo group's role permissions
+
+### 3. useUserContext Hook (`core/hooks/useUserContext.ts`)
+
+Updated `getContextPermissions()` to use actual permission checks in personal context:
+
+```typescript
+const getContextPermissions = () => {
+ // In personal context, check AirQo group permissions if they exist
+ if (userContext === 'personal') {
+ // Device view is always available in personal context for owned devices
+ // But for other permissions, check the user's AirQo group permissions
+ return {
+ canViewDevices: true, // Always true for personal devices
+ canViewSites,
+ canViewUserManagement,
+ canViewAccessControl,
+ canViewOrganizations: false,
+ canViewNetworks: false,
+ };
+ }
+
+ // For organizational contexts, use the regular permission checks
+ return {
+ canViewDevices,
+ canViewSites,
+ canViewUserManagement,
+ canViewAccessControl,
+ canViewNetworks,
+ };
+};
+```
+
+Changes:
+- Instead of hardcoding `false` for all permissions except `canViewDevices`
+- Now uses the actual permission hooks (`canViewSites`, `canViewUserManagement`, etc.)
+- These will automatically use AirQo group permissions thanks to the `usePermission` hook changes
+
+## How It Works
+
+### Scenario 1: User in Personal Context WITH AirQo Group
+
+1. User is in `personal` context
+2. No `activeGroup` is set (personal mode)
+3. User calls `usePermission(PERMISSIONS.SITE.VIEW)`
+4. Hook detects personal context without active group
+5. Retrieves user's AirQo group using `permissionService.getAirQoGroup(user)`
+6. Uses AirQo group as the `activeOrganization` for permission check
+7. Permission service checks if the user's AirQo group role has `SITE.VIEW` permission
+8. Returns `true` or `false` based on actual permissions
+
+### Scenario 2: User in Personal Context WITHOUT AirQo Group
+
+1. User is in `personal` context
+2. No `activeGroup` is set
+3. User has no AirQo group
+4. Permission check falls through to default behavior
+5. Returns `false` (no permissions without a group)
+
+### Scenario 3: User in Organizational Context
+
+1. User is in `airqo-internal` or `external-org` context
+2. `activeGroup` is set
+3. Permission checks use the `activeGroup` directly
+4. No AirQo group fallback needed
+
+## Benefits
+
+1. **Consistent Permission Model**: Same RBAC system works across all contexts
+2. **Flexibility**: Users can have different permissions in their AirQo group that apply to personal context
+3. **No Hardcoding**: Permissions are data-driven, not hardcoded in the UI
+4. **Backward Compatible**: Doesn't break existing behavior for organizational contexts
+5. **Fine-Grained Control**: Admins can control what users can do in personal context via AirQo group roles
+
+## Use Cases
+
+This implementation enables scenarios like:
+
+- **AirQo staff** with appropriate permissions can view sites even in personal mode
+- **Admin users** can access user management features from personal context
+- **Technical staff** can perform maintenance operations regardless of context
+- **Analysts** can access data export features in personal mode
+
+## Testing
+
+To test this implementation:
+
+1. **Create a user with AirQo group** that has specific permissions (e.g., `SITE.VIEW`)
+2. **Switch to personal context**
+3. **Check if permission-gated features** are accessible based on AirQo group permissions
+4. **Verify permission checks** return correct values
+
+Example:
+```typescript
+// In a component
+const canViewSites = usePermission(PERMISSIONS.SITE.VIEW);
+// In personal context, this will check the user's AirQo group permissions
+
+// In a feature
+{canViewSites && }
+// This button will show in personal mode if the user's AirQo group has SITE.VIEW
+```
+
+## Notes
+
+- The `canViewDevices` permission is always `true` in personal context because users should always see their own devices
+- Organizations-specific features (`canViewOrganizations`, `canViewNetworks`) remain `false` in personal context as they don't make sense there
+- The implementation preserves all existing behavior for `airqo-internal` and `external-org` contexts
diff --git a/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md
new file mode 100644
index 0000000000..fcea6f4919
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md
@@ -0,0 +1,79 @@
+# Access Control Architecture: Scopes & Contexts
+
+> **Note**: This document describes the current access control model, focusing on the decoupling of Context and Scope.
+
+## Core Concepts
+
+The application uses a **Scope-based Architecture** to decouple *where a user is* (Context) from *how the application behaves* (Scope).
+
+### 1. Context (`userContext`)
+Represents the user's current "location" or logical grouping.
+- **`personal`**: The default view for users. This encompasses both "normal" users managing their own devices AND AirQo staff managing the system (via their personal workspace).
+- **`external-org`**: Specific to users belonging to third-party organizations (e.g., KCCA, NEMA) when they have explicitly switched to that organization's view.
+
+### 2. Scope (`userScope`)
+Represents the *behavioral mode* of the application. This determines navigation, available features, and data visibility.
+- **`personal` Scope**: Focuses on "My Resources". Shows personal devices, user-specific data, and for AirQo staff, allows system management.
+- **`organisation` Scope**: Focuses on "Shared Resources". Shows organization-wide dashboards, all devices in the org, team management. This is strictly for External Organizations.
+
+## The Model: Context → Scope Mapping
+
+We enforce strict scoping rules to ensure consistent user experience:
+
+| Context | Mapped Scope | Behavior Description |
+| :--- | :--- | :--- |
+| `personal` | `personal` | **"My Workspace"**. User manages their own devices, follows their own sites. AirQo staff also perform system admin here. |
+| `external-org` | `organisation` | **"Organization Dashboard"**. Traditional admin view for external partners to manage their specific entity. |
+
+> **Key Architecture Note**: We previously had an `airqo-internal` context, but this has been deprecated. AirQo group members now operate strictly within the `personal` context. They do not "administer the AirQo Organization" like an external org admin; instead, they administrate the *System* from their *Personal Workspace*.
+
+## Permission Resolution Strategy
+
+How we decide if a user can do something (`usePermission` hook) depends on the Scope.
+
+### 1. In `organisation` Scope (External Orgs)
+* **Source**: The `activeGroup` (the organization the user is currently viewing).
+* **Logic**: Standard RBAC. Does the user's role *in this specific group* have the permission?
+* **Example**: A KCCA Admin viewing KCCA dashboard checks KCCA group permissions.
+
+### 2. In `personal` Scope (Personal Workspace)
+This uses a layered approach to allow seamless system management.
+
+* **Layer 1: Ownership (Implicit)**
+ * Users always have owner-level access to their *own* resources (e.g., `canViewDevices` is always true for own devices).
+
+* **Layer 2: Active Group Assignment (Universal)**
+ * **Problem**: Users in Personal Mode need permissions to access certain features (like Admin tools), but traditionally had no active group.
+ * **Solution**: If a user belongs to the **AirQo** group, the system now automatically sets it as their `activeGroup` even when in Personal Mode.
+ * **Result**: Standard permissions work out-of-the-box. An Admin sees their admin features because they genuinely have the AirQo group active.
+
+### Implementation Details
+
+#### `userSlice.ts`
+Logic defining the active group assignment:
+```typescript
+if (!action.payload) {
+ // Personal Mode
+ // If user belongs to AirQo group, keep it active!
+ const airqoGroup = state.userGroups.find((g) => g.grp_title === 'airqo');
+ if (airqoGroup) {
+ state.activeGroup = airqoGroup;
+ // ...
+ }
+}
+```
+
+## Matrix: Who Sees What Where?
+
+| Feature | AirQo Staff (Personal Context) | External Org Admin (Org Context) |
+| :--- | :--- | :--- |
+| **Scope** | `personal` | `organisation` |
+| **My Devices** | ✅ Visible | ❌ Hidden |
+| **Org Dashboard** | ❌ Hidden | ✅ Visible |
+| **Network Mgmt** | ✅ Visible (via AirQo Group) | ❌ Hidden |
+| **Site Mgmt** | ✅ Visible (via AirQo Group) | ✅ Visible (Org sites only) |
+
+## Summary
+* **External Users**: Strict isolation in `external-org` context. They only see what's in their Org.
+* **AirQo Staff**: Exist in `personal` context. They "live" in a Personal Scope but "borrow" powers from the AirQo System Group to manage global resources like Networks and Sites.
+* **Context Switching**: Available to any user who belongs to multiple organizations.
diff --git a/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md
new file mode 100644
index 0000000000..ecc2771ed1
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md
@@ -0,0 +1,104 @@
+# AirQo RBAC Feature Access – Living Document
+
+---
+
+## RBAC Principles
+
+- **Transparency:** Prefer disabling over hiding, unless security or irrelevance dictates otherwise.
+- **Security:** Hide destructive or sensitive actions from all but the highest-level admins.
+- **Motivation:** Disabled features should explain what's required to enable them.
+- **Clarity:** Users should never wonder if something is missing due to a bug.
+
+---
+
+## Feature Access Matrix
+
+| **Feature/Action** | **Super Admin** | **Org Admin** | **Technician** | **Analyst** | **Developer** | **Viewer** | **Guest/No Org** | **Disable for** | **Hide for** |
+|----------------------------|:--------------:|:-------------:|:--------------:|:-----------:|:-------------:|:----------:|:----------------:|:-------------------------------|:------------------------------------|
+| Device Claim | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org |
+| Device Deploy | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org |
+| Device Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org |
+| Device Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Device View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org |
+| Device Assignment/Share | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Site Create/Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org |
+| Site Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Site View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org |
+| Analytics Dashboard | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org |
+| Data Export | ✔️ | ✔️ | ❌ | ✔️ | ✔️ | ❌ | ❌ | All except Super/Org Admin/Analyst/Dev | Guest/No Org, Technician, Viewer |
+| User Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Role Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Organization Management | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin |
+| Settings Edit | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Settings View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org |
+| Premium/Upgrade | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | All except premium orgs/users | Guest/No Org, non-premium orgs |
+| Destructive Actions | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin |
+
+**Legend:**
+- ✔️ = Has access (feature enabled)
+- ❌ = No access (feature disabled or hidden)
+- **Disable for** = Show feature, but disabled, for these roles
+- **Hide for** = Don't show feature at all for these roles
+
+---
+
+## Sidebar & Application Feature Mapping
+
+### Sidebar Structure (Example)
+
+| **Sidebar Item** | **Sub-Items** | **Permission** | **Disable for** | **Hide for** |
+|-------------------------|------------------------------|-------------------------------|--------------------------------|--------------------------------------|
+| Dashboard | — | DEVICE.VIEW | — | Guest/No Org |
+| Network Map | — | SITE.VIEW | — | Guest/No Org |
+| Devices | Overview, My Devices | DEVICE.VIEW | All except permitted roles | Guest/No Org |
+| | Deploy, Claim, Share | DEVICE.DEPLOY/UPDATE/ASSIGN | All except permitted roles | Guest/No Org, Viewer |
+| Sites | Overview, Create, Grids | SITE.VIEW, SITE.CREATE | All except permitted roles | Guest/No Org |
+| Cohorts | Overview, Create | DEVICE.VIEW, DEVICE.UPDATE | All except permitted roles | Guest/No Org |
+| User Management | — | USER.VIEW, USER.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Access Control | Roles, Permissions | ROLE.VIEW, ROLE.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Organizations | — | ORGANIZATION.VIEW | All except Super Admin | All except Super Admin |
+| Team | Overview, Invite, Roles | USER.VIEW, USER.CREATE, ROLE.VIEW | All except permitted roles | Guest/No Org, Technician, Analyst |
+| Profile Settings | — | — | — | — |
+
+**Notes:**
+- For each sidebar item, use your `PermissionGuard` or `usePermission` to determine if it should be enabled or disabled.
+- If a user is in the "Hide for" group, do not render the sidebar item at all.
+- If a user is in the "Disable for" group, render the item but make it non-clickable and show a tooltip explaining why.
+
+---
+
+### Application Features (Major Screens/Actions)
+
+| **Feature/Screen** | **Permission** | **Disable for** | **Hide for** |
+|----------------------------|-------------------------------|--------------------------------|--------------------------------------|
+| Claim Device | DEVICE.CLAIM | All except permitted roles | Guest/No Org |
+| Deploy Device | DEVICE.DEPLOY | All except permitted roles | Guest/No Org |
+| Device Assignment/Share | DEVICE.ASSIGN | All except permitted roles | Guest/No Org, Viewer |
+| Delete Device | DEVICE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Create Site | SITE.CREATE | All except permitted roles | Guest/No Org |
+| Delete Site | SITE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Export Data | ANALYTICS.DATA_EXPORT | All except Analyst/Dev/Admin | Guest/No Org, Technician, Viewer |
+| Invite User | USER.INVITE | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Edit Roles | ROLE.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| Organization Management | ORGANIZATION.UPDATE | All except Super Admin | All except Super Admin |
+| Edit Settings | SETTINGS.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst |
+| View Settings | SETTINGS.VIEW | All except permitted roles | Guest/No Org |
+| Destructive Actions | SYSTEM.SUPER_ADMIN | All except Super Admin | All except Super Admin |
+
+---
+
+## How to Maintain This Document
+
+- **Update** whenever new features or roles are added.
+- **Review** with product/design quarterly or after major releases.
+- **Link** to this doc from engineering and product wikis.
+- **Add** a "Request Access" or "Upgrade" workflow for disabled features as needed.
+
+---
+
+## Future Updates / Open Questions
+
+- [ ] Are there new roles or custom roles to consider?
+- [ ] Should some features be "requestable" (user can click to request access)?
+- [ ] Are there features that should be hidden for compliance/security reasons?
+- [ ] Should we add analytics to track how often users see disabled features?
\ No newline at end of file
diff --git a/src/vertex-template/app/_docs/internal/device-status.md b/src/vertex-template/app/_docs/internal/device-status.md
new file mode 100644
index 0000000000..570832d312
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/device-status.md
@@ -0,0 +1,36 @@
+# Vertex Device Status Guide
+
+This document defines the current logic used to determine a device's status in Vertex.
+
+## Core Concept
+
+Our logic is based on two independent boolean fields from the API:
+
+### 1. rawOnlineStatus (Raw Data)
+
+- **What it means:** Is the device sending raw, unprocessed data to the cloud?
+- **Values:** Transmitting (true) / Not Transmitting (false)
+
+### 2. isOnline (Calibrated Data)
+
+- **What it means:** Is processed, calibrated data available for use?
+- **Values:** Data Ready (true) / Processing or No Data (false)
+
+These fields are independent. For example, a device can be transmitting raw data (`rawOnlineStatus: true`) while the calibrated data is still processing (`isOnline: false`).
+
+## Status Definitions
+
+The combination of these two fields determines the device's primary status:
+
+| Status | Color | rawOnlineStatus (Raw Data) | isOnline (Calibrated Data) | Logic Summary |
+|--------|-------|----------------------------|----------------------------|---------------|
+| **Operational** | Green | true (Transmitting) | true (Data Ready) | Device transmitting • Data ready for use. |
+| **Transmitting** | Blue | true (Transmitting) | false (Processing) | Receiving data • Processing calibration |
+| **Data Available** | Yellow | false (Not Transmitting) | true (Data Ready) | Using recent data • Not currently transmitting. |
+| **Not Transmitting** | Gray | false (Not Transmitting) | false (Processing) | No recent data from the device. |
+
+## Special Case: Invalid Date
+
+| Status | Color | Logic |
+|--------|-------|-------|
+| **Invalid Date** | Purple | This status appears if the device reports a `lastActive` timestamp that is more than 5 minutes in the future. This indicates a device-level clock or configuration error and overrides the logic above. |
diff --git a/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md
new file mode 100644
index 0000000000..5384e7e88c
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md
@@ -0,0 +1,43 @@
+# Vertex Adapter Foundation
+
+## Purpose
+
+This document describes the adapter foundation for `create-vertex-app` v1. The foundation defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials.
+
+## Files
+
+- `core/adapters/types.ts`: v1 adapter contract.
+- `core/adapters/index.ts`: adapter factory selected by `vertex.config.ts`.
+- `core/adapters/mock.ts`: mock adapter implementation.
+- `core/adapters/mock-fixtures.ts`: typed fixture data.
+- `core/adapters/airqo.ts`: AirQo wrapper around the existing service layer.
+
+## V1 Adapter Choices
+
+- `mock`: implemented now and intended as the generated template default.
+- `airqo`: wraps existing `core/apis/*` services without rewriting endpoint behavior.
+
+Generic REST remains out of scope for v1.
+
+## Integration Boundary
+
+This foundation does not refactor existing React Query hooks yet. The next integration step should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components.
+
+## Mock Adapter Coverage
+
+The mock adapter includes:
+
+- Seeded user, group, role, and network data.
+- Four devices covering online, offline, private, deployed, and undeployed states.
+- Three sites with coordinates and status variety.
+- Two cohorts with device membership.
+- Device and site activity events.
+- Latest and historical reading data.
+- Successful no-op mutations for claim, deploy, recall, create, update, maintenance, and group assignment flows.
+
+## Contributor Guidance
+
+- Keep fixture data realistic but small.
+- Add adapter methods only when current v1 screens need them.
+- Keep AirQo endpoint mapping out of `mock.ts`; `airqo.ts` owns live API service mapping.
+- Do not add REST adapter behavior in v1.
diff --git a/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md
new file mode 100644
index 0000000000..8a7c2fe7a2
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md
@@ -0,0 +1,47 @@
+# Vertex Template Config Contract
+
+## Purpose
+
+`vertex.config.ts` is the single deployer-owned configuration file for a Vertex instance. The future `create-vertex-app` CLI should generate this file from wizard answers.
+
+Shared validation, defaults, and TypeScript types live in `core/config/vertex-config.ts`.
+
+## V1 Defaults
+
+V1 supports only:
+
+- `api.adapter: "mock"` for local evaluation without credentials.
+- `api.adapter: "airqo"` for the current AirQo API-backed app.
+- `auth.provider: "none"` for mock/local template evaluation.
+- `auth.provider: "airqo"` for the existing AirQo auth/session flow.
+
+Generic REST adapters, plugin systems, deploy wizards, and config admin screens are v2 work.
+
+## Required Config Groups
+
+- `org`: visible organization identity, logo, primary color, support email, website, and slug.
+- `api`: adapter choice, AirQo API base URL when applicable, and public measurement URL base.
+- `auth`: auth provider and system group slug used by AirQo-mode permission logic.
+- `features`: v1 feature flags for maps, bulk deploy, sites, CSV export, readings, user management, desktop download, app launcher, shipping, and network requests.
+- `map`: default center, zoom, and tile provider.
+- `links`: docs, privacy, cookie policy, analytics, and desktop download URLs.
+
+## Validation Rules
+
+- Organization name, short name, slug, logo, primary color, and support email must be valid.
+- `org.primaryColor` must be a hex color.
+- `api.adapter` must be `mock` or `airqo`.
+- `auth.provider` must be `none` or `airqo`.
+- `api.baseUrl` is required when `api.adapter` is `airqo`.
+- `auth.provider: "airqo"` requires `api.adapter: "airqo"`.
+- Map latitude must be between -90 and 90.
+- Map longitude must be between -180 and 180.
+- Map zoom must be between 0 and 22.
+
+## Contributor Guidance
+
+- Do not add new top-level config groups without maintainer approval.
+- Prefer adding feature-specific options under an existing group.
+- Keep `vertex.config.example.ts` mock-first.
+- Keep `vertex.config.ts` AirQo-compatible until template extraction is complete.
+- Do not implement the generic REST adapter in v1.
diff --git a/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md
new file mode 100644
index 0000000000..dfeac094d5
--- /dev/null
+++ b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md
@@ -0,0 +1,137 @@
+# Vertex Template Coupling Audit
+
+## Purpose
+
+This audit maps the current Vertex app coupling that must be addressed before extracting a reusable `vertex-template` and publishing `create-vertex-app`.
+
+The current app is a Next.js App Router project. Routes live in `app/`, feature UI in `components/features/`, layout in `components/layout/`, API services in `core/apis/`, React Query hooks in `core/hooks/`, auth/proxy routes in `app/api/`, and shared state/config in `core/`, `context`, and `lib`.
+
+## Summary
+
+V1 should not rewrite Vertex. It should isolate existing behavior behind configuration and adapters.
+
+Primary coupling areas:
+
+- AirQo branding in metadata, layout, login, errors, download, footer, topbar, title bar, app launcher, and support links.
+- AirQo API access through `NEXT_PUBLIC_API_URL`, proxy routes, `secureApiProxyClient`, and `core/apis/*`.
+- AirQo group/network assumptions in auth, Redux user state, permissions, hooks, and device/site filtering.
+- Hardcoded public measurement endpoint examples.
+- Map defaults and Mapbox-only assumptions.
+- Optional AirQo/ops features that should be feature-flagged or excluded from v1 template defaults.
+
+V1 adapter support should be `mock` and `airqo` only. Generic REST remains v2.
+
+## Priority Legend
+
+- `P0`: Required before template extraction.
+- `P1`: Required before public v1 release, can follow adapter foundation.
+- `P2`: Can remain in AirQo mode or be deferred if hidden by feature flags.
+- `Keep`: Intentional dependency or acceptable package/library usage.
+
+## Coupling Inventory
+
+| Area | Files | Current coupling | Replacement | Priority |
+|---|---|---|---|---|
+| App metadata | `app/layout.tsx` | Hardcoded `AirQo Vertex`, AirQo description, `vertex.airqo.net`, AirQo image path | Read org/app metadata from `vertexConfig.org` and template defaults | P0 |
+| Page title provider | `context/page-title-context.tsx` | `APP_TITLE = "AirQo Vertex"` | Use configured app/org title | P0 |
+| Login branding | `app/login/page.tsx` | AirQo logo and copy about AirQo open data channels | Use configured logo, org name, support/value copy | P0 |
+| Auth error page | `app/auth-error/page.tsx` | AirQo logo, `support@airqo.net`, AirQo copyright | Use config support email, logo, org name | P0 |
+| Topbar | `components/layout/topbar.tsx` | AirQo logo, account labels | Use configured logo and neutral account labels | P0 |
+| Primary sidebar | `components/layout/primary-sidebar.tsx` | AirQo logo, Vertex label, AirQo admin role names | Logo from config; admin visibility from config and permission mode | P1 |
+| Desktop titlebar | `components/layout/desktop-titlebar.tsx` | AirQo logo fallback and `AirQo Vertex` text | Use config branding and generic fallback | P1 |
+| Footer | `components/layout/Footer.tsx` | AirQo copyright and platform name | Config org name and app name | P1 |
+| App launcher | `components/layout/AppDropdown.tsx` | AirQo ecosystem links, AirQo app store copy | Hide by default or make AirQo-mode-only feature | P2 |
+| Download page | `app/download/page.tsx`, `components/features/download/*`, `core/constants/app-downloads.ts` | AirQo Vertex Desktop release URL/copy | Feature-flag desktop download; config-driven URL if enabled | P2 |
+| Cookie banner | `components/features/auth/cookie-info-banner.tsx`, `lib/envConstants.ts` | AirQo cookie copy and default AirQo policy URL | Configurable policy URL and org name | P1 |
+| Feedback | `components/features/feedback/*` | Event key `airqo:feedback:open`, "Send feedback to AirQo" | Configurable org name; event key can be generic | P1 |
+| Public support links | `app/not-found.tsx`, `components/ui/permission-tooltip.tsx` | AirQo support/docs links | Configurable support and docs URLs | P1 |
+| Theme color | `app/globals.css`, `tailwind.config.ts` | Primary blue is hardcoded in CSS variables | Inject/configure `org.primaryColor`; keep Tailwind variable pattern | P0 |
+| API base URLs | `lib/envConstants.ts`, `core/urls.tsx`, `core/config/proxyConfig.ts` | Requires `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_API_TOKEN`; Analytics default is AirQo | Move API base URL/token requirements behind adapter mode | P0 |
+| Axios/proxy client | `core/utils/secureApiProxyClient.ts`, `core/utils/proxyClient.ts`, `core/apis/axiosConfig.ts` | AirQo proxy behavior and auth headers | Keep for AirQo adapter; mock adapter bypasses it | P0 |
+| Dynamic proxy route | `app/api/[...path]/route.ts` | Proxies to configured AirQo-style backend path | Keep AirQo-mode only; avoid requiring in mock mode | P1 |
+| Devices service | `core/apis/devices.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter; do not rewrite endpoints in v1 | P0 |
+| Sites service | `core/apis/sites.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter | P0 |
+| Cohorts service | `core/apis/cohorts.ts` | Direct AirQo endpoint paths | Wrap needed methods via adapter | P0 |
+| Networks service | `core/apis/networks.ts`, `core/services/network-service.ts` | AirQo users/networks endpoints and `ADMIN_SECRET` flows | AirQo adapter or AirQo-mode-only admin feature | P1 |
+| Grids service | `core/apis/grids.ts` | Direct backend paths | Include only if grid feature remains enabled; otherwise flag | P2 |
+| Users/auth service | `core/apis/users.ts`, `app/api/auth/[...nextauth]/*` | AirQo login/profile/session shape | Keep for AirQo mode; mock/no-auth path needed for template evaluation | P0 |
+| Cloudinary | `core/apis/cloudinary.ts`, `app/api/cloudinary/upload/route.ts` | Requires Cloudinary env vars | Feature-flag uploads or make optional config | P2 |
+| Slack logging | `app/api/log-to-slack/route.ts`, `lib/logger.ts` | Slack webhook route | Keep optional; disabled when env missing | P2 |
+| Mapbox | `components/features/mini-map/mini-map.tsx`, `components/features/location-autocomplete/LocationAutocomplete.tsx` | Mapbox token, Mapbox geocoding, Kampala default center | Config map provider, default center/zoom, optional Mapbox token | P1 |
+| Measurement examples | `components/features/devices/device-measurements-api-card.tsx`, `components/features/sites/site-measurements-api-card.tsx`, `components/features/cohorts/cohort-measurements-api-card.tsx`, `components/features/grids/grid-measurements-api-card.tsx` | Hardcoded `https://api.airqo.net/api/v2/...` examples | Build URLs from config/API docs base; hide in mock mode if misleading | P1 |
+| Auth default org | `core/auth/authProvider.tsx` | Prefers group named `airqo` as default organization | Adapter/config-defined system group or AirQo-mode-only logic | P0 |
+| Redux user context | `core/redux/slices/userSlice.ts` | Treats `airqo` group as personal/elevated context | Abstract into configured system group or mock local context | P0 |
+| Permissions | `core/permissions/*`, `core/hooks/usePermissions.ts` | AirQo RBAC names and AirQo group fallback | Keep AirQo mode; add simple mock/no-auth permissions for v1 evaluation | P0 |
+| Device hooks | `core/hooks/useDevices.ts` | Imports `devices` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 |
+| Site hooks | `core/hooks/useSites.ts` | Imports `sites` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 |
+| Network hooks | `core/hooks/useNetworks.ts` | Sorts `airqo` network first; imports APIs directly | Adapter-backed data and config-defined preferred network | P1 |
+| User context hook | `core/hooks/useUserContext.ts` | AirQo-specific personal/org scope comments and assumptions | Configurable auth/permission mode | P1 |
+| Device claim/import | `components/features/claim/*`, `components/features/devices/import-device-modal.tsx` | Claim AirQo Device copy, reserved `airqo` cohort, AirQo device placeholders | Configurable device vocabulary; reserved names AirQo-mode only | P1 |
+| Device deploy | `components/features/devices/deploy-device-component.tsx` | Direct fetch to `/api/v2/devices/my-devices?claim_status=claimed`; default network `airqo` | Move data call into hook/adapter; default network from config/current context | P0 |
+| Device create/admin | `components/features/devices/create-device-modal.tsx`, `app/(authenticated)/admin/networks/[id]/page.tsx` | "Add AirQo Device" copy and AirQo network branch | Configurable copy; AirQo branch only in AirQo mode | P1 |
+| Public visibility copy | `components/features/home/network-visibility-card.tsx`, `components/features/cohorts/cohort-detail-card.tsx` | "AirQo Map" copy | Configurable public map/product name | P1 |
+| Empty states | `components/features/home/HomeEmptyState.tsx`, `components/features/cohorts/cohorts-empty-state.tsx` | AirQo device copy | Configurable device terminology | P1 |
+| Shipping labels | `components/features/shipping/*` | AirQo Air Quality Monitor label text | AirQo-mode only or config-driven label brand | P2 |
+| Network request flows | `components/features/networks/*`, `app/api/network/route.ts`, `app/api/devices/network-creation-requests/*` | AirQo backend admin-secret network creation | AirQo-mode admin feature; not required for mock default | P2 |
+| Query/cache keys | `core/providers/query-provider.tsx`, `core/utils/clientCache.ts` | `airqo:vertex:*` storage keys | Generic `vertex:*` or config namespace to avoid cross-instance collisions | P1 |
+| Internal docs/changelog | `app/changelog.md`, `app/_docs/internal/*`, `app/_docs/deprecated/*` | AirQo internal history and architecture | Exclude or heavily sanitize from `vertex-template` | P2 |
+
+## Direct API Call Notes
+
+Most backend access is already centralized, which is good for adapter extraction:
+
+- `core/apis/devices.ts`
+- `core/apis/sites.ts`
+- `core/apis/cohorts.ts`
+- `core/apis/grids.ts`
+- `core/apis/networks.ts`
+- `core/apis/users.ts`
+- `core/apis/roles.ts`
+- `core/apis/permissions.ts`
+- `core/apis/organizations.ts`
+
+The main component-level exceptions found are:
+
+- `components/features/devices/deploy-device-component.tsx`: direct `fetch("/api/v2/devices/my-devices?claim_status=claimed")`
+- `components/features/networks/create-network-form.tsx`: posts to `/api/network`
+- `components/features/networks/network-request-dialog.tsx`: posts to `/api/devices/network-creation-requests`
+- `components/features/mini-map/mini-map.tsx`: fetches Mapbox reverse geocoding
+- `components/features/location-autocomplete/LocationAutocomplete.tsx`: fetches Mapbox suggestions
+- Measurement API card components hardcode public AirQo API examples.
+
+## Recommended Migration Order
+
+1. Add typed config and defaults.
+2. Add adapter interface and mock adapter.
+3. Add AirQo adapter wrapping existing `core/apis`.
+4. Refactor `core/hooks` to use adapter-backed access.
+5. Remove component-level direct backend calls or move them behind hooks/adapters.
+6. Config-drive branding, metadata, theme, map defaults, support links, and feature flags.
+7. Hide or AirQo-mode-gate admin/network/shipping/download/app-launcher features.
+8. Sanitize docs and extract template.
+
+## Out of Scope For V1
+
+- Generic REST adapter.
+- Production deploy wizard.
+- Plugin system.
+- Managed hosting.
+- Template upgrade command.
+- Admin UI for config.
+- Replacing `@airqo/icons-react` solely because of package name; icon imports can stay unless brand/legal review requires removal.
+
+## Open Questions For Maintainers
+
+- Should `@airqo/icons-react` remain a public dependency in the generic template?
+- Should desktop download and shipping workflows ship disabled by default or be excluded from template v1?
+- What is the approved default mock organization name, logo, and primary color?
+- Should mock mode use `auth.provider = "none"` with a synthetic local user, or keep NextAuth with seeded credentials?
+- Should public measurement API cards appear in mock mode, or only in AirQo mode?
+
+## Contributor Guidance
+
+This audit is documentation only. Follow-up implementation tasks should avoid broad file ownership conflicts:
+
+- Core maintainers should own config, adapter contracts, auth, and hook refactors.
+- Open-source contributors can safely work on branding copy, measurement cards, docs, fixture data, and feature-flagged UI after the config contract lands.
+- PRs should touch narrow areas and avoid project-wide formatting.
diff --git a/src/vertex-template/app/api/[...path]/route.ts b/src/vertex-template/app/api/[...path]/route.ts
new file mode 100644
index 0000000000..d923ddff56
--- /dev/null
+++ b/src/vertex-template/app/api/[...path]/route.ts
@@ -0,0 +1,48 @@
+
+import { createProxyHandler } from '@/core/utils/proxyClient';
+import { getAuthOptions } from '@/core/config/proxyConfig';
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Dynamic API proxy handler
+ *
+ * This file handles all API proxy requests by forwarding them to the actual API
+ * but without exposing sensitive tokens on the client side.
+ *
+ * For routes that need API_TOKEN authentication, the token is added on the server side.
+ * For routes requiring JWT auth, the authorization header is forwarded from the client.
+ *
+ * Path pattern: /api/[...path]
+ */
+
+interface RouteParams {
+ params: {
+ path: string[];
+ };
+}
+
+// Determine if the request needs authentication based on the path or explicit header
+const handler = (request: NextRequest, { params }: RouteParams) => {
+ // Extract path segments
+ const { path } = params;
+
+ // Skip NextAuth routes - they should be handled by NextAuth directly
+ if (path && path.length > 0 && path[0] === 'auth') {
+ return NextResponse.json({ error: 'Not Found' }, { status: 404 });
+ }
+
+ // Check for explicit auth type header
+ const authTypeHeader = request.headers.get('x-auth-type');
+
+ // Get optimized auth options
+ const options = getAuthOptions(path, authTypeHeader || undefined);
+
+ // Create and execute the appropriate proxy handler
+ return createProxyHandler(options)(request, { params });
+};
+
+export const GET = handler;
+export const POST = handler;
+export const PUT = handler;
+export const DELETE = handler;
+export const PATCH = handler;
diff --git a/src/vertex-template/app/api/auth/[...nextauth]/options.ts b/src/vertex-template/app/api/auth/[...nextauth]/options.ts
new file mode 100644
index 0000000000..9ee1c7bc11
--- /dev/null
+++ b/src/vertex-template/app/api/auth/[...nextauth]/options.ts
@@ -0,0 +1,355 @@
+import { NextAuthOptions } from 'next-auth';
+import CredentialsProvider from 'next-auth/providers/credentials';
+import { users } from '@/core/apis/users';
+import { jwtDecode } from 'jwt-decode';
+import type {
+ LoginCredentials,
+ LoginResponse,
+ DecodedToken,
+} from '@/app/types/users';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+import logger from '@/lib/logger';
+import { getApiBaseUrl, isHCaptchaEnabled } from '@/lib/envConstants';
+import { normalizeOAuthAccessToken } from '@/core/auth/oauth-session';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+const isTokenExpired = (exp?: number): boolean => {
+ if (!exp) return false;
+ return Date.now() / 1000 > exp;
+};
+
+const getValidUrl = (value?: string) => {
+ const url = value?.trim();
+
+ if (!url) {
+ return null;
+ }
+
+ try {
+ return new URL(url).toString().replace(/\/$/, '');
+ } catch {
+ return null;
+ }
+};
+
+const getAzureContainerAppsUrl = () => {
+ const appName = process.env.CONTAINER_APP_NAME;
+ const dnsSuffix = process.env.CONTAINER_APP_ENV_DNS_SUFFIX;
+
+ if (!appName || !dnsSuffix || !appName.endsWith('-vertex-preview')) {
+ return null;
+ }
+
+ return `https://${appName}.${dnsSuffix}`;
+};
+
+const azureContainerAppsUrl = getAzureContainerAppsUrl();
+const nextAuthUrl = getValidUrl(process.env.NEXTAUTH_URL);
+const nextAuthUrlInternal = getValidUrl(process.env.NEXTAUTH_URL_INTERNAL);
+
+if (nextAuthUrl) {
+ process.env.NEXTAUTH_URL = nextAuthUrl;
+} else {
+ delete process.env.NEXTAUTH_URL;
+}
+
+if (nextAuthUrlInternal) {
+ process.env.NEXTAUTH_URL_INTERNAL = nextAuthUrlInternal;
+} else {
+ delete process.env.NEXTAUTH_URL_INTERNAL;
+}
+
+if (!process.env.NEXTAUTH_URL && azureContainerAppsUrl) {
+ process.env.NEXTAUTH_URL = azureContainerAppsUrl;
+ process.env.NEXTAUTH_URL_INTERNAL =
+ process.env.NEXTAUTH_URL_INTERNAL || azureContainerAppsUrl;
+ process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true';
+}
+
+const authSecret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET;
+const configuredCookieDomain =
+ process.env.NEXTAUTH_COOKIE_DOMAIN?.trim() || undefined;
+const getCookieDomain = () => {
+ if (!configuredCookieDomain) {
+ return undefined;
+ }
+
+ const referenceUrl = process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL_INTERNAL;
+ if (!referenceUrl) {
+ return configuredCookieDomain;
+ }
+
+ try {
+ const host = new URL(referenceUrl).hostname.toLowerCase();
+ const normalizedDomain = configuredCookieDomain.replace(/^\./, '').toLowerCase();
+ const hostMatches =
+ host === normalizedDomain || host.endsWith(`.${normalizedDomain}`);
+
+ if (hostMatches) {
+ return configuredCookieDomain;
+ }
+
+ logger.warn(
+ '[NextAuth] NEXTAUTH_COOKIE_DOMAIN does not match NEXTAUTH_URL host; disabling cookie domain override.',
+ { configuredCookieDomain, host }
+ );
+ return undefined;
+ } catch {
+ logger.warn(
+ '[NextAuth] Invalid NEXTAUTH_URL while validating cookie domain; disabling cookie domain override.',
+ { configuredCookieDomain, referenceUrl }
+ );
+ return undefined;
+ }
+};
+const cookieDomain = getCookieDomain();
+const cookieOptions = {
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ path: '/',
+ secure: isProduction,
+ domain: cookieDomain,
+};
+
+if (isProduction && !authSecret) {
+ logger.error('[NextAuth] CRITICAL: NEXTAUTH_SECRET is missing in production environment!');
+}
+
+if (isProduction && !process.env.NEXTAUTH_URL && !process.env.AUTH_TRUST_HOST) {
+ process.env.AUTH_TRUST_HOST = 'true';
+ logger.warn('[NextAuth] WARNING: NEXTAUTH_URL is missing. Dynamic host detection will be used.');
+}
+
+interface OAuthProfilePayload {
+ _id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ userName?: string;
+ organization?: string;
+ privilege?: string;
+ profilePicture?: string;
+}
+
+interface OAuthProfileResponse {
+ success: boolean;
+ message?: string;
+ data?: OAuthProfilePayload;
+}
+
+const fetchOAuthProfile = async (
+ accessToken: string
+): Promise => {
+ try {
+ const profileUrl = `${getApiBaseUrl()}/users/profile/enhanced`;
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ const response = await fetch(profileUrl, {
+ method: 'GET',
+ cache: 'no-store',
+ signal: controller.signal,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `JWT ${accessToken}`,
+ },
+ }).finally(() => clearTimeout(timeoutId));
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const payload = (await response.json()) as OAuthProfileResponse;
+ if (!payload?.success || !payload.data?._id) {
+ return null;
+ }
+
+ return payload.data;
+ } catch (error) {
+ logger.error('Error fetching OAuth profile', { error });
+ return null;
+ }
+};
+
+export const options: NextAuthOptions = {
+ secret: authSecret,
+ useSecureCookies: isProduction,
+ providers: [
+ CredentialsProvider({
+ id: 'credentials',
+ name: 'credentials',
+ credentials: {
+ userName: { label: 'Username', type: 'text' },
+ password: { label: 'Password', type: 'password' },
+ oauthToken: { label: 'OAuth Token', type: 'text' },
+ oauthProvider: { label: 'OAuth Provider', type: 'text' },
+ captchaToken: { label: 'Captcha Token', type: 'text' },
+ },
+ async authorize(credentials) {
+ const oauthToken = normalizeOAuthAccessToken(
+ typeof credentials?.oauthToken === 'string' ? credentials.oauthToken : ''
+ );
+
+ if (oauthToken) {
+ const profile = await fetchOAuthProfile(oauthToken);
+
+ if (!profile) {
+ logger.warn('OAuth authorization failed: profile fetch returned null.');
+ return null;
+ }
+
+ // Use JWT decode to get extra fields if the profile doesn't have them
+ let decoded: DecodedToken | null = null;
+ try {
+ decoded = jwtDecode(oauthToken);
+ } catch {
+ decoded = null;
+ }
+
+ return {
+ id: profile._id,
+ email: profile.email,
+ name: `${profile.firstName} ${profile.lastName}`.trim() || profile.email,
+ userName: profile.userName || decoded?.userName || profile.email,
+ accessToken: oauthToken,
+ organization: profile.organization || decoded?.organization || '',
+ privilege: profile.privilege || decoded?.privilege || '',
+ firstName: profile.firstName,
+ lastName: profile.lastName,
+ country: decoded?.country || '',
+ timezone: decoded?.timezone || '',
+ phoneNumber: decoded?.phoneNumber || '',
+ exp: decoded?.exp,
+ };
+ }
+
+ if (!credentials?.userName || !credentials?.password) {
+ logger.warn('Authorize call with missing credentials.');
+ throw new Error('Username and password are required.');
+ }
+
+ const hcaptchaEnabled = isHCaptchaEnabled();
+ const captchaToken =
+ typeof credentials.captchaToken === 'string'
+ ? credentials.captchaToken.trim()
+ : '';
+
+ if (hcaptchaEnabled && !captchaToken) {
+ logger.warn('Authorize call with missing CAPTCHA token.');
+ throw new Error('CAPTCHA validation is required.');
+ }
+
+ try {
+ const loginResponse = (await users.loginWithDetails({
+ userName: credentials.userName,
+ password: credentials.password,
+ captchaToken: hcaptchaEnabled ? captchaToken : undefined,
+ } as LoginCredentials)) as LoginResponse;
+
+ if (loginResponse?.token) {
+ const decoded = jwtDecode(loginResponse.token);
+
+ return {
+ id: decoded._id,
+ email: decoded.email,
+ name: `${decoded.firstName} ${decoded.lastName}`,
+ userName: decoded.userName,
+ accessToken: loginResponse.token,
+ organization: decoded.organization,
+ privilege: decoded.privilege,
+ firstName: decoded.firstName,
+ lastName: decoded.lastName,
+ country: decoded.country || '',
+ timezone: decoded.timezone || '',
+ phoneNumber: decoded.phoneNumber || '',
+ exp: decoded.exp,
+ };
+ }
+
+ logger.warn('Login API returned success status but no token.', {
+ userName: credentials.userName,
+ });
+ throw new Error(
+ 'Authentication server returned an invalid response.'
+ );
+ } catch (error) {
+ const errorMessage = getApiErrorMessage(error);
+ logger.error('Authentication error during login', {
+ userName: credentials.userName,
+ error: errorMessage,
+ });
+ throw new Error(errorMessage);
+ }
+ },
+ }),
+ ],
+
+ cookies: {
+ sessionToken: {
+ name: isProduction
+ ? '__Secure-next-auth.session-token'
+ : 'next-auth.session-token',
+ options: cookieOptions,
+ },
+ },
+
+ session: {
+ strategy: 'jwt',
+ maxAge: 24 * 60 * 60, // 24 hours
+ },
+
+ jwt: {
+ maxAge: 24 * 60 * 60, // 24 hours
+ },
+
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.id = user.id;
+ token.accessToken = user.accessToken;
+ token.userName = user.userName;
+ token.organization = user.organization;
+ token.privilege = user.privilege;
+ token.firstName = user.firstName;
+ token.lastName = user.lastName;
+ token.country = user.country;
+ token.timezone = user.timezone;
+ token.phoneNumber = user.phoneNumber;
+ token.exp = user.exp;
+ }
+ return token;
+ },
+
+ async session({ session, token }) {
+ if (isTokenExpired(token.exp as number | undefined)) {
+ return { ...session, user: null };
+ }
+
+ if (token) {
+ session.user = {
+ ...session.user,
+ id: token.id as string,
+ accessToken: token.accessToken as string,
+ userName: token.userName as string,
+ organization: token.organization as string,
+ privilege: token.privilege as string,
+ firstName: token.firstName as string,
+ lastName: token.lastName as string,
+ country: token.country as string,
+ timezone: token.timezone as string,
+ phoneNumber: token.phoneNumber as string,
+ exp: token.exp,
+ };
+ }
+ return session;
+ },
+ },
+
+ pages: {
+ signIn: '/login',
+ error: '/auth-error',
+ },
+
+ debug: false,
+};
diff --git a/src/vertex-template/app/api/auth/[...nextauth]/route.ts b/src/vertex-template/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000000..566e1e9c28
--- /dev/null
+++ b/src/vertex-template/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,37 @@
+import NextAuth from 'next-auth';
+import { options } from './options';
+import type { NextRequest } from 'next/server';
+
+const getRequestOrigin = (req: NextRequest) => {
+ const forwardedProto = req.headers.get('x-forwarded-proto') || 'https';
+ const forwardedHost = req.headers.get('x-forwarded-host');
+ const host = forwardedHost || req.headers.get('host');
+
+ if (!host) {
+ return null;
+ }
+
+ return `${forwardedProto}://${host}`;
+};
+
+const handler = NextAuth(options);
+
+const setRuntimeAuthUrls = (req: NextRequest) => {
+ const requestOrigin = getRequestOrigin(req);
+
+ if (requestOrigin) {
+ process.env.NEXTAUTH_URL = requestOrigin;
+ process.env.NEXTAUTH_URL_INTERNAL = requestOrigin;
+ process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true';
+ }
+};
+
+export async function GET(req: NextRequest, context: { params: Record }) {
+ setRuntimeAuthUrls(req);
+ return handler(req, context);
+}
+
+export async function POST(req: NextRequest, context: { params: Record }) {
+ setRuntimeAuthUrls(req);
+ return handler(req, context);
+}
diff --git a/src/vertex-template/app/api/cloudinary/upload/route.ts b/src/vertex-template/app/api/cloudinary/upload/route.ts
new file mode 100644
index 0000000000..c441450e42
--- /dev/null
+++ b/src/vertex-template/app/api/cloudinary/upload/route.ts
@@ -0,0 +1,107 @@
+import { NextRequest, NextResponse } from 'next/server';
+import crypto from 'crypto';
+import { getCloudinaryName, getCloudinaryApiKey, getCloudinaryApiSecret } from '@/lib/envConstants';
+
+export async function POST(req: NextRequest) {
+ try {
+ const formData = await req.formData();
+ const file = formData.get('file') as File | null;
+ const requestedFolder = formData.get('folder') as string || 'feedback';
+ const tags = formData.get('tags') as string || '';
+
+ const ALLOWED_FOLDERS = new Set(['feedback']);
+ const folder = ALLOWED_FOLDERS.has(requestedFolder) ? requestedFolder : 'feedback';
+
+ const VALID_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
+ const MAX_BYTES = 2 * 1024 * 1024; // 2MB
+
+ if (!file) {
+ return NextResponse.json({ success: false, error: 'No file provided' }, { status: 400 });
+ }
+
+ if (!VALID_TYPES.has(file.type)) {
+ return NextResponse.json({ success: false, error: 'Invalid file type' }, { status: 400 });
+ }
+
+ if (file.size > MAX_BYTES) {
+ return NextResponse.json({ success: false, error: 'File too large (max 2MB)' }, { status: 400 });
+ }
+
+ let cloudName: string;
+ let apiKey: string;
+ let apiSecret: string;
+
+ try {
+ cloudName = getCloudinaryName();
+ apiKey = getCloudinaryApiKey();
+ apiSecret = getCloudinaryApiSecret();
+ } catch (configError: unknown) {
+ const errMsg = configError instanceof Error ? configError.message : 'Cloudinary configuration is incomplete on the server.';
+ return NextResponse.json({
+ success: false,
+ error: errMsg
+ }, { status: 500 });
+ }
+
+ const timestamp = Math.round(new Date().getTime() / 1000).toString();
+
+ // Prepare parameters to sign (sorted alphabetically)
+ const paramsToSign: Record = {
+ folder,
+ timestamp,
+ };
+ if (tags) {
+ paramsToSign.tags = tags;
+ }
+
+ const sortedKeys = Object.keys(paramsToSign).sort();
+ const paramString = sortedKeys
+ .map(key => `${key}=${paramsToSign[key]}`)
+ .join('&');
+
+ const stringToSign = paramString + apiSecret;
+ const signature = crypto
+ .createHash('sha1')
+ .update(stringToSign)
+ .digest('hex');
+
+ // Build the request body for Cloudinary
+ const cloudinaryFormData = new FormData();
+ cloudinaryFormData.append('file', file);
+ cloudinaryFormData.append('api_key', apiKey);
+ cloudinaryFormData.append('timestamp', timestamp);
+ cloudinaryFormData.append('signature', signature);
+ cloudinaryFormData.append('folder', folder);
+ if (tags) {
+ cloudinaryFormData.append('tags', tags);
+ }
+
+ const cloudinaryUrl = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`;
+ const response = await fetch(cloudinaryUrl, {
+ method: 'POST',
+ body: cloudinaryFormData,
+ signal: AbortSignal.timeout(15000),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ return NextResponse.json({
+ success: false,
+ error: result.error?.message || 'Failed to upload to Cloudinary'
+ }, { status: response.status });
+ }
+
+ return NextResponse.json({
+ success: true,
+ secure_url: result.secure_url,
+ public_id: result.public_id,
+ });
+ } catch (error: unknown) {
+ const errMsg = error instanceof Error ? error.message : 'Server error during upload';
+ return NextResponse.json({
+ success: false,
+ error: errMsg
+ }, { status: 500 });
+ }
+}
diff --git a/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts
new file mode 100644
index 0000000000..007bf60540
--- /dev/null
+++ b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { options } from "@/app/api/auth/[...nextauth]/options";
+import logger from "@/lib/logger";
+import { networkService } from "@/core/services/network-service";
+
+async function getAuthToken(): Promise {
+ const session = await getServerSession(options);
+ return (session as { user?: { accessToken?: string } })?.user?.accessToken || null;
+}
+
+export async function PUT(
+ req: NextRequest,
+ { params }: { params: { id: string; action: string } }
+) {
+ try {
+ const { id, action } = params;
+ const allowedActions = ["approve", "deny", "review"];
+
+ if (!allowedActions.includes(action)) {
+ return NextResponse.json({ message: "Invalid action" }, { status: 400 });
+ }
+
+ const token = await getAuthToken();
+ if (!token) {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+
+ const adminSecret = process.env.ADMIN_SECRET;
+ if (!adminSecret) {
+ return NextResponse.json({ message: "Server configuration error" }, { status: 500 });
+ }
+
+ const body = await req.json();
+ const notes = body.reviewer_notes || "";
+
+ const responseData = await networkService.updateNetworkRequestStatus(
+ id,
+ action,
+ notes,
+ token,
+ adminSecret
+ );
+
+ return NextResponse.json(responseData, { status: 200 });
+ } catch (error: unknown) {
+ const err = error as { message: string; status?: number; data?: unknown };
+ logger.error(`Error updating network request status in route handler: ${err.message}`);
+
+ return NextResponse.json(
+ err.data || { message: err.message || "Internal server error" },
+ { status: err.status || 500 }
+ );
+ }
+}
diff --git a/src/vertex-template/app/api/devices/network-creation-requests/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/route.ts
new file mode 100644
index 0000000000..0d0c742933
--- /dev/null
+++ b/src/vertex-template/app/api/devices/network-creation-requests/route.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { options } from "../../auth/[...nextauth]/options";
+import logger from "@/lib/logger";
+import { networkService } from "@/core/services/network-service";
+import type { NetworkRequestValues } from "@/components/features/networks/schema";
+
+export async function GET() {
+ try {
+ const session = await getServerSession(options);
+ const token = (session as { user?: { accessToken?: string } })?.user?.accessToken;
+
+ if (!token) {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+
+ const adminSecret = process.env.ADMIN_SECRET;
+ if (!adminSecret) {
+ return NextResponse.json({ message: "Server configuration error" }, { status: 500 });
+ }
+
+ const data = await networkService.getNetworkCreationRequests(token, adminSecret);
+
+ return NextResponse.json({ network_creation_requests: data, success: true }, { status: 200 });
+ } catch (error: unknown) {
+ if (error instanceof Error && error.message === "NOT_FOUND") {
+ return NextResponse.json({ message: "Resource not found" }, { status: 404 });
+ }
+ const err = error as { message: string; status?: number; data?: unknown };
+ logger.error(`Error fetching network requests in route handler: ${err.message}`);
+ return NextResponse.json(
+ err.data || { message: err.message || "Internal server error" },
+ { status: err.status || 500 }
+ );
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ let body: NetworkRequestValues;
+ try {
+ body = (await req.json()) as NetworkRequestValues;
+ } catch {
+ return NextResponse.json({ message: "Invalid JSON payload" }, { status: 400 });
+ }
+
+ const data = await networkService.submitNetworkRequest(body);
+ return NextResponse.json(data, { status: 200 });
+ } catch (error: unknown) {
+ const err = error as { message: string; status?: number; data?: unknown };
+ logger.error(`Error submitting network request in route handler: ${err.message}`);
+ return NextResponse.json(
+ err.data || { message: err.message || "Internal server error" },
+ { status: err.status || 500 }
+ );
+ }
+}
diff --git a/src/vertex-template/app/api/log-to-slack/route.ts b/src/vertex-template/app/api/log-to-slack/route.ts
new file mode 100644
index 0000000000..854585382d
--- /dev/null
+++ b/src/vertex-template/app/api/log-to-slack/route.ts
@@ -0,0 +1,257 @@
+import { NextRequest, NextResponse } from 'next/server';
+import axios, { AxiosError } from 'axios';
+
+interface ErrorResponse {
+ status?: number;
+ statusText?: string;
+ data?: unknown;
+}
+
+interface ErrorContext {
+ status?: number;
+ method?: string;
+ url?: string;
+ error?: {
+ status?: number;
+ response?: {
+ status?: number;
+ };
+ };
+ [key: string]: unknown;
+}
+
+interface LogBody {
+ level: 'info' | 'warn' | 'error';
+ message: string;
+ context: ErrorContext;
+}
+
+interface SlackTextBlock {
+ type: 'plain_text' | 'mrkdwn';
+ text: string;
+ emoji?: boolean;
+}
+
+interface SlackSectionBlock {
+ type: 'section';
+ text?: SlackTextBlock;
+ fields?: SlackTextBlock[];
+}
+
+interface SlackHeaderBlock {
+ type: 'header';
+ text: SlackTextBlock;
+}
+
+interface SlackAttachment {
+ color: string;
+ blocks: SlackSectionBlock[];
+}
+
+interface SlackPayload {
+ blocks: (SlackHeaderBlock | SlackSectionBlock)[];
+ attachments: SlackAttachment[];
+}
+
+// Get environment info
+function getEnvironmentInfo() {
+ const env =
+ process.env.NEXT_PUBLIC_ALLOW_DEV_TOOLS ??
+ process.env.NODE_ENV ??
+ 'development';
+
+ const environment =
+ env === 'staging' || env === 'production' ? env : 'development';
+
+ return { environment };
+}
+
+// Check if an error should be ignored for Slack notifications
+function shouldIgnoreError(context: ErrorContext): boolean {
+ const ignoredStatusCodes = [400, 404];
+
+ const status = context.status;
+ const errorStatus = context.error?.status;
+ const responseStatus = context.error?.response?.status;
+
+ return (
+ ignoredStatusCodes.includes(status ?? -1) ||
+ ignoredStatusCodes.includes(errorStatus ?? -1) ||
+ ignoredStatusCodes.includes(responseStatus ?? -1)
+ );
+}
+
+// Simple in-memory cache for deduplication
+const errorCache = new Set();
+const ERROR_CACHE_TTL = 1000 * 60 * 15; // 15 minutes
+
+export async function POST(request: NextRequest) {
+ try {
+ const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
+
+ if (!SLACK_WEBHOOK_URL) {
+ console.warn(
+ 'Slack webhook URL not configured - check your .env file'
+ );
+ console.warn('Expected environment variable: SLACK_WEBHOOK_URL');
+ return NextResponse.json(
+ {
+ success: true,
+ skipped: true,
+ reason: 'Slack webhook URL not configured',
+ },
+ { status: 200 }
+ );
+ }
+
+ const body = (await request.json()) as LogBody;
+ const { level, message, context } = body;
+
+ if (shouldIgnoreError(context)) {
+ return NextResponse.json({
+ success: true,
+ skipped: true,
+ reason: 'Ignored status code',
+ });
+ }
+
+ const fingerprint = `${level}:${message}:${JSON.stringify(context)}`;
+ if (errorCache.has(fingerprint)) {
+ return NextResponse.json({
+ success: true,
+ skipped: true,
+ reason: 'Deduplication',
+ });
+ }
+
+ errorCache.add(fingerprint);
+ setTimeout(() => {
+ errorCache.delete(fingerprint);
+ }, ERROR_CACHE_TTL);
+
+ const { environment } = getEnvironmentInfo();
+
+ const emoji =
+ level === 'error' ? '🔴' : level === 'warn' ? '⚠️' : 'ℹ️';
+ const color =
+ level === 'error'
+ ? '#FF0000'
+ : level === 'warn'
+ ? '#FFA500'
+ : '#36C5F0';
+
+ const slackPayload: SlackPayload = {
+ blocks: [
+ {
+ type: 'header',
+ text: {
+ type: 'plain_text',
+ text: `${emoji} ${level.toUpperCase()} [${environment}]: ${message}`,
+ emoji: true,
+ },
+ },
+ ],
+ attachments: [
+ {
+ color,
+ blocks: [
+ {
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: `*Environment:*\n${environment}`,
+ },
+ {
+ type: 'mrkdwn',
+ text: `*Time:*\n${new Date().toISOString()}`,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ // Add URL if available in context
+ if (context.url) {
+ slackPayload.attachments[0].blocks.push({
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: `*URL:*\n${context.url}`,
+ },
+ ],
+ });
+ }
+
+ // Add error details if available
+ if (context.status || context.method) {
+ slackPayload.attachments[0].blocks.push({
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: `*Request Details:*\n${context.method ?? 'Unknown'}${
+ context.status ? ` (${context.status})` : ''
+ }`,
+ },
+ ],
+ });
+ }
+
+ // Add context details if available
+ const { ...filteredContext } = context;
+ if (Object.keys(filteredContext).length > 0) {
+ slackPayload.attachments[0].blocks.push({
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: `*Context:*\n\`\`\`${JSON.stringify(
+ filteredContext,
+ null,
+ 2
+ )}\`\`\``,
+ },
+ });
+ }
+
+ const response = await axios.post(SLACK_WEBHOOK_URL, slackPayload, {
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.data !== 'ok') {
+ console.warn('⚠️ [SLACK API] Unexpected response from Slack:', response.data);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error: unknown) {
+ const err = error as AxiosError;
+
+ console.error('Error sending to Slack:', {
+ message: err.message,
+ status: err.response?.status,
+ data: err.response?.data,
+ config: {
+ url: err.config?.url?.substring(0, 50) + '...',
+ method: err.config?.method,
+ },
+ });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: err.message,
+ details: {
+ status: err.response?.status,
+ statusText: err.response?.statusText,
+ },
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/vertex-template/app/api/network/route.ts b/src/vertex-template/app/api/network/route.ts
new file mode 100644
index 0000000000..63d8296dac
--- /dev/null
+++ b/src/vertex-template/app/api/network/route.ts
@@ -0,0 +1,95 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { options } from "../auth/[...nextauth]/options";
+import logger from "@/lib/logger";
+import { CreateNetworkPayload, CreateNetworkResponse } from "@/core/apis/networks";
+import axios from "axios";
+import { networkFormSchema } from "@/components/features/networks/schema";
+
+/**
+ * Retrieves the access token from the server-side session.
+ * @returns The access token or null if not found.
+ */
+async function getAuthToken(): Promise {
+ const session = await getServerSession(options);
+
+ if (!session) {
+ logger.warn("getServerSession returned null");
+ return null;
+ }
+
+ const accessToken = (session as unknown as { user?: { accessToken?: string } })?.user?.accessToken;
+
+ if (!accessToken) {
+ logger.warn(`Session found but no accessToken in session.user. Session keys: ${Object.keys(session).join(', ')}`);
+ }
+
+ return accessToken || null;
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ logger.info("Sensor Manufacturer creation request received");
+
+ const body = await req.json();
+ const validationResult = networkFormSchema.safeParse(body);
+
+ if (!validationResult.success) {
+ logger.warn(`Invalid payload: ${JSON.stringify(validationResult.error.issues)}`);
+ return NextResponse.json(
+ { message: "Invalid payload", errors: validationResult.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const networkData = validationResult.data;
+
+ const token = await getAuthToken();
+ if (!token) {
+ logger.error("No JWT token found in session");
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+
+ const authHeader = token.startsWith("JWT ") ? token : `JWT ${token}`;
+
+ const adminSecret = process.env.ADMIN_SECRET;
+ if (!adminSecret) {
+ const envKeys = Object.keys(process.env).filter(key => key.includes('SECRET') || key.includes('ADMIN'));
+ logger.error(`ADMIN_SECRET is not configured on the server. NODE_ENV: ${process.env.NODE_ENV}, Related env keys: ${envKeys.join(', ')}`);
+ return NextResponse.json({ message: "Server configuration error." }, { status: 500 });
+ }
+
+ logger.debug("ADMIN_SECRET found, constructing payload");
+
+ const payload: CreateNetworkPayload = { ...networkData, admin_secret: adminSecret };
+
+ const backendApiUrl = `${process.env.NEXT_PUBLIC_API_URL}/users/networks`;
+
+ const apiResponse = await axios.post(backendApiUrl, payload, {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ "X-Auth-Type": "JWT",
+ },
+ });
+
+ logger.info(`Sensor Manufacturer created successfully - ID: ${apiResponse.data.created_network?._id}`);
+
+ return NextResponse.json(apiResponse.data, { status: 200 });
+ } catch (error: unknown) {
+ const err = error as Error;
+ logger.error(`API route error: ${err.message}`);
+
+ if (axios.isAxiosError(error) && error.response) {
+ const errorMessage = error.response.data?.message || error.response.data?.errors?.[0]?.message || 'Unknown error';
+ logger.error(`Upstream API error - Status: ${error.response.status} ${error.response.statusText}, Message: ${errorMessage}`);
+
+ return NextResponse.json(
+ error.response.data || { message: "An error occurred" },
+ { status: error.response.status }
+ );
+ }
+
+ return NextResponse.json({ message: "An internal server error occurred." }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/auth-error/page.tsx b/src/vertex-template/app/auth-error/page.tsx
new file mode 100644
index 0000000000..551b586c57
--- /dev/null
+++ b/src/vertex-template/app/auth-error/page.tsx
@@ -0,0 +1,154 @@
+"use client"
+
+import { Suspense } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import Link from 'next/link'
+import Image from 'next/image'
+import { AlertCircle, ArrowLeft, ShieldAlert, Settings, Mail, UserPlus, Link as LinkIcon } from 'lucide-react'
+import ReusableButton from "@/components/shared/button/ReusableButton"
+import { vertexConfig } from "@/vertex.config";
+
+type ErrorType = {
+ title: string;
+ message: string;
+ icon: React.ReactNode;
+ description: string;
+}
+
+const ERROR_MAP: Record = {
+ Configuration: {
+ title: 'Configuration Error',
+ message: 'There is a problem with the server configuration.',
+ description: 'This is usually caused by a missing environment variable or a mismatch in the NEXTAUTH_URL. Please check the server logs.',
+ icon: ,
+ },
+ AccessDenied: {
+ title: 'Access Denied',
+ message: 'You do not have permission to sign in.',
+ description: 'Your account might not have the necessary privileges, or your sign-in was restricted by a security policy.',
+ icon: ,
+ },
+ Verification: {
+ title: 'Link Expired',
+ message: 'The sign-in link is no longer valid.',
+ description: 'The verification token has either expired or has already been used. Please try signing in again to receive a new link.',
+ icon: ,
+ },
+ OAuthSignin: {
+ title: 'Sign-in Failed',
+ message: 'Could not start the external authentication process.',
+ description: 'An error occurred while trying to connect to the authentication provider. This could be a temporary network issue.',
+ icon: ,
+ },
+ OAuthCallback: {
+ title: 'Authentication Failed',
+ message: 'Error during the external authentication callback.',
+ description: 'The response from the authentication provider was invalid or could not be processed.',
+ icon: ,
+ },
+ OAuthCreateAccount: {
+ title: 'Account Creation Failed',
+ message: 'Could not create a new user account via OAuth.',
+ description: 'There was an issue creating your account profile. This might be due to missing required information from the provider.',
+ icon: ,
+ },
+ EmailCreateAccount: {
+ title: 'Account Creation Failed',
+ message: 'Could not create a new user account via email.',
+ description: 'An error occurred while setting up your account. Please try again or contact support.',
+ icon: ,
+ },
+ Callback: {
+ title: 'Callback Error',
+ message: 'An error occurred during the authentication callback.',
+ description: 'The authentication process failed at the final validation step.',
+ icon: ,
+ },
+ OAuthAccountNotLinked: {
+ title: 'Account Linked Elsewhere',
+ message: 'This email is already associated with another provider.',
+ description: 'To confirm your identity, please sign in using the provider you originally used for this email address.',
+ icon: ,
+ },
+ Default: {
+ title: 'Authentication Error',
+ message: 'An unexpected error occurred.',
+ description: 'Something went wrong during the sign-in process. Please try again or contact our support team if the issue persists.',
+ icon: ,
+ },
+}
+
+function AuthErrorContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams()
+ const errorKey = searchParams.get('error') || 'Default'
+ const error = ERROR_MAP[errorKey] || ERROR_MAP.Default
+
+ return (
+
+
+
+
+
+
+
+
+ {error.icon}
+
+
{error.title}
+
{error.message}
+
+
+ {error.description}
+
+ {errorKey !== 'Default' && (
+
+ Error Code: {errorKey}
+
+ )}
+
+
+
+
+ router.push("/login")}
+ >
+ Back to Login
+
+
+ Contact Support
+
+
+
+
+
+ © {new Date().getFullYear()} {vertexConfig.org.name}. All rights reserved.
+
+
+ )
+}
+
+export default function AuthErrorPage() {
+ return (
+
+
+
+ }>
+
+
+ )
+}
diff --git a/src/vertex-template/app/changelog.md b/src/vertex-template/app/changelog.md
new file mode 100644
index 0000000000..f6af1b535a
--- /dev/null
+++ b/src/vertex-template/app/changelog.md
@@ -0,0 +1,9 @@
+# Vertex Template - Changelog
+
+## Version 1.0.0
+**Released:** June 2026
+
+### Initial Release
+- Created standalone boilerplate template for Vertex applications.
+- Removed AirQo-specific branding, components, and hardcoded API dependencies.
+- Added mock adapter for initial scaffolding.
diff --git a/src/vertex-template/app/client-layout.tsx b/src/vertex-template/app/client-layout.tsx
new file mode 100644
index 0000000000..0b3ab7eb5b
--- /dev/null
+++ b/src/vertex-template/app/client-layout.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import type React from 'react';
+import dynamic from 'next/dynamic';
+
+const Toaster = dynamic(
+ () => import('@/components/shared/toast/ReusableToast').then(mod => mod.Toaster),
+ { ssr: false }
+);
+
+import Providers from "./providers"
+
+import { Session } from "next-auth";
+
+export default function ClientLayout({
+ children,
+ session
+}: {
+ children: React.ReactNode;
+ session: Session | null;
+}) {
+ return (
+
+
+
{children}
+
+
+ )
+}
diff --git a/src/vertex-template/app/error.tsx b/src/vertex-template/app/error.tsx
new file mode 100644
index 0000000000..fb9ca55f27
--- /dev/null
+++ b/src/vertex-template/app/error.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import { useRouter } from "next/navigation"
+import logger from "@/lib/logger"
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const router = useRouter()
+ useEffect(() => {
+ logger.error('Application error', error);
+ }, [error])
+
+ return (
+
+
+
Something went wrong!
+
+ We apologize for the inconvenience. The application encountered an unexpected error.
+
+
+ router.push("/login")} variant="outline">
+ Go to Login
+
+ reset()}>Try again
+
+
+
+ )
+}
diff --git a/src/vertex-template/app/favicon-16x16.png b/src/vertex-template/app/favicon-16x16.png
new file mode 100644
index 0000000000..7515f3dddc
Binary files /dev/null and b/src/vertex-template/app/favicon-16x16.png differ
diff --git a/src/vertex-template/app/favicon-32x32.png b/src/vertex-template/app/favicon-32x32.png
new file mode 100644
index 0000000000..a3fa9b234b
Binary files /dev/null and b/src/vertex-template/app/favicon-32x32.png differ
diff --git a/src/vertex-template/app/favicon.ico b/src/vertex-template/app/favicon.ico
new file mode 100644
index 0000000000..1a323901ae
Binary files /dev/null and b/src/vertex-template/app/favicon.ico differ
diff --git a/src/vertex-template/app/fonts/GeistMonoVF.woff b/src/vertex-template/app/fonts/GeistMonoVF.woff
new file mode 100644
index 0000000000..f2ae185cbf
Binary files /dev/null and b/src/vertex-template/app/fonts/GeistMonoVF.woff differ
diff --git a/src/vertex-template/app/fonts/GeistVF.woff b/src/vertex-template/app/fonts/GeistVF.woff
new file mode 100644
index 0000000000..1b62daacff
Binary files /dev/null and b/src/vertex-template/app/fonts/GeistVF.woff differ
diff --git a/src/vertex-template/app/globals.css b/src/vertex-template/app/globals.css
new file mode 100644
index 0000000000..96cdca0c58
--- /dev/null
+++ b/src/vertex-template/app/globals.css
@@ -0,0 +1,543 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --vertex-ui-top-offset: 0px;
+ /* Colors */
+ --background: 246 246 247;
+ --foreground: 28 29 32;
+ --primary: 20 95 255;
+ --primary-foreground: 255 255 255;
+ --secondary: 241 245 249;
+ --secondary-foreground: 15 23 42;
+ --muted: 241 245 249;
+ --muted-foreground: 91 105 124;
+ --accent: 241 245 249;
+ --accent-foreground: 15 23 42;
+ --destructive: 239 68 68;
+ --destructive-foreground: 255 255 255;
+ --border: 226 232 240;
+ --input: 226 232 240;
+ --ring: 20 95 255;
+ --card: 255 255 255;
+ --card-foreground: 15 23 42;
+ --popover: 255 255 255;
+ --popover-foreground: 15 23 42;
+ --text: 31 41 55;
+ --heading: 16 24 40;
+
+ /* Primary color variations for highlights */
+ --primary-50: 235 245 255;
+ /* Very light blue */
+ --primary-100: 219 234 254;
+ /* Light blue */
+ --primary-700: 3 105 161;
+ /* Dark blue */
+ --primary-800: 7 89 133;
+ /* Darker blue */
+ --primary-900: 12 74 110;
+ /* Darkest blue */
+
+ /* Layout */
+ --radius: 0.5rem;
+
+ /* Scrollbars */
+ --scrollbar-track: 241 245 249;
+ --scrollbar-thumb: 100 116 139;
+ --scrollbar-hover: 51 65 85;
+
+ /* Typography */
+ --font-mono:
+ 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro',
+ monospace;
+ --heading-weight: 500;
+ --heading-line-height: 1.25;
+ --heading-letter-spacing: -0.025em;
+ }
+
+ .dark {
+ --background: 29 31 32;
+ --foreground: 237 237 237;
+ --primary: 20 95 255;
+ --primary-foreground: 255 255 255;
+ --secondary: 30 41 59;
+ --secondary-foreground: 248 250 252;
+ --muted: 30 41 59;
+ --muted-foreground: 148 163 184;
+ --accent: 30 41 59;
+ --accent-foreground: 248 250 252;
+ --destructive: 220 38 38;
+ --destructive-foreground: 255 255 255;
+ --border: 51 65 85;
+ --input: 51 65 85;
+ --ring: 20 95 255;
+ --card: 29 31 32;
+ --card-foreground: 248 250 252;
+ --popover: 29 31 32;
+ --popover-foreground: 248 250 252;
+ --text: 249 250 251;
+ --heading: 255 255 255;
+ --scrollbar-track: 30 41 59;
+ --scrollbar-thumb: 148 163 184;
+ --scrollbar-hover: 51 65 85;
+ }
+}
+
+/* ==========================================================================
+ BASE STYLES
+ ========================================================================== */
+html {
+ overflow: hidden;
+}
+
+body,
+html {
+ color: rgb(var(--foreground));
+ background: rgb(var(--background));
+ font-family: var(--font-inter), Arial, Helvetica, sans-serif;
+}
+
+.dark body,
+.dark html {
+ background: rgb(var(--background));
+}
+
+/* ==========================================================================
+ TYPOGRAPHY SYSTEM
+ ========================================================================== */
+
+@layer base {
+
+ /* Base heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ color: rgb(var(--heading));
+ font-weight: var(--heading-weight);
+ line-height: var(--heading-line-height);
+ letter-spacing: var(--heading-letter-spacing);
+ margin: 0;
+ }
+
+ /* Individual heading sizes */
+ h1 {
+ font-size: 2.25rem;
+ line-height: 1.1;
+ letter-spacing: -0.04em;
+ }
+
+ h2 {
+ font-size: 1.875rem;
+ line-height: 1.2;
+ letter-spacing: -0.03em;
+ }
+
+ h3 {
+ font-size: 1.5rem;
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+ }
+
+ h4 {
+ font-size: 1.25rem;
+ line-height: 1.3;
+ }
+
+ h5 {
+ font-size: 1.125rem;
+ line-height: 1.4;
+ }
+
+ h6 {
+ font-size: 1rem;
+ line-height: 1.5;
+ }
+
+ /* Links */
+ a {
+ color: rgb(var(--primary));
+ text-decoration: none;
+ transition: color 0.2s ease;
+ }
+
+ button:focus:not(:focus-visible),
+ input:focus:not(:focus-visible),
+ textarea:focus:not(:focus-visible),
+ select:focus:not(:focus-visible) {
+ outline: 2px solid rgb(var(--ring));
+ outline-offset: 2px;
+ }
+
+ /* Lists */
+ ul,
+ ol {
+ color: rgb(var(--foreground));
+ line-height: 1.6;
+ }
+
+ ul {
+ list-style-type: disc;
+ padding-left: 1.5rem;
+ }
+
+ ol {
+ list-style-type: decimal;
+ padding-left: 1.5rem;
+ }
+
+ /* Dropdown menus - remove list styles */
+ [role='listbox'] {
+ list-style: none;
+ padding-left: 0;
+ }
+
+ /* Toasts and alerts - remove list styles */
+ [role='alert'],
+ [role='status'],
+ [aria-live='polite'],
+ [aria-live='assertive'] {
+ list-style: none !important;
+ }
+
+ [role='alert'] *,
+ [role='status'] *,
+ [aria-live='polite'] *,
+ [aria-live='assertive'] * {
+ list-style: none !important;
+ }
+
+ /* Blockquotes */
+ blockquote {
+ border-left: 4px solid rgb(var(--border));
+ padding-left: 1rem;
+ margin: 1.5rem 0;
+ color: rgb(var(--muted-foreground));
+ font-style: italic;
+ }
+
+ /* Code blocks */
+ code {
+ font-family: var(--font-mono);
+ font-size: 0.875em;
+ background: rgb(var(--muted));
+ color: rgb(var(--foreground));
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ border: 1px solid rgb(var(--border));
+ }
+
+ pre {
+ font-family: var(--font-mono);
+ background: rgb(var(--muted));
+ color: rgb(var(--foreground));
+ padding: 1rem;
+ border-radius: 0.5rem;
+ border: 1px solid rgb(var(--border));
+ overflow-x: auto;
+ line-height: 1.5;
+ }
+
+ pre code {
+ background: transparent;
+ border: none;
+ padding: 0;
+ }
+
+ /* Form elements */
+ input,
+ textarea,
+ select {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ }
+
+ /* Focus styles - removed blue ring */
+ button:focus,
+ input:focus,
+ textarea:focus,
+ select:focus {
+ outline: none;
+ }
+
+ /* Alternative focus styles for accessibility */
+ textarea:focus {
+ border-color: rgb(var(--ring));
+ box-shadow: 0 0 0 1px rgb(var(--ring));
+ }
+
+ a:focus {
+ background-color: rgb(var(--accent));
+ color: rgb(var(--accent-foreground));
+ }
+
+ /* Responsive typography */
+ @media (max-width: 640px) {
+ h1 {
+ font-size: 1.875rem;
+ }
+
+ h2 {
+ font-size: 1.5rem;
+ }
+
+ h3 {
+ font-size: 1.25rem;
+ }
+ }
+}
+
+/* ==========================================================================
+ UTILITIES
+ ========================================================================== */
+
+@layer utilities {
+ .text-balance {
+ text-wrap: balance;
+ }
+
+ /* Text color utilities */
+ .text-primary {
+ color: rgb(var(--primary));
+ }
+
+ .text-primary-foreground {
+ color: rgb(var(--primary-foreground));
+ }
+
+ .text-secondary {
+ color: rgb(var(--secondary));
+ }
+
+ .text-secondary-foreground {
+ color: rgb(var(--secondary-foreground));
+ }
+
+ .text-muted {
+ color: rgb(var(--muted-foreground));
+ }
+
+ .text-accent {
+ color: rgb(var(--accent));
+ }
+
+ .text-accent-foreground {
+ color: rgb(var(--accent-foreground));
+ }
+
+ .text-destructive {
+ color: rgb(var(--destructive));
+ }
+
+ .text-destructive-foreground {
+ color: rgb(var(--destructive-foreground));
+ }
+
+ .text-foreground {
+ color: rgb(var(--foreground));
+ }
+
+ .text-background {
+ color: rgb(var(--background));
+ }
+
+ .text-text {
+ color: rgb(var(--text));
+ }
+
+ .text-heading {
+ color: rgb(var(--heading));
+ }
+}
+
+/* ==========================================================================
+ COMPONENTS
+ ========================================================================== */
+
+/* Spinner */
+.secondary-main-loader {
+ width: 50px;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background:
+ radial-gradient(farthest-side, rgb(var(--primary)) 94%, #0000) top/8px 8px no-repeat,
+ conic-gradient(#0000 30%, rgb(var(--primary)));
+ -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0);
+ animation: l13 1s infinite linear;
+}
+
+@keyframes l13 {
+ 100% {
+ transform: rotate(1turn);
+ }
+}
+
+/* ==========================================================================
+ SCROLLBARS
+ ========================================================================== */
+
+/* Hide scrollbars */
+.custom-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+/* Default scrollbar styles */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: rgb(var(--scrollbar-track));
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgb(var(--scrollbar-thumb));
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgb(var(--scrollbar-hover));
+}
+
+/* Horizontal scrollbars */
+.country-scroll-bar {
+ overflow-x: auto;
+ overflow-y: hidden;
+ cursor: pointer;
+}
+
+.country-scroll-bar::-webkit-scrollbar {
+ height: 4px;
+ display: none;
+}
+
+.country-scroll-bar:hover::-webkit-scrollbar {
+ display: block;
+}
+
+.map-scrollbar::-webkit-scrollbar {
+ height: 2px;
+}
+
+/* ==========================================================================
+ FLOWBITE TOOLTIP STYLES
+ ========================================================================== */
+
+/* Basic tooltip styling for Flowbite tooltips */
+.tooltip {
+ background-color: rgb(var(--card)) !important;
+ /* gray-800 */
+ color: rgb(var(--card-foreground)) !important;
+ /* white */
+ border-radius: 0.375rem !important;
+ /* rounded-md */
+ padding: 0.5rem 0.75rem !important;
+ font-size: 0.875rem !important;
+ /* text-sm */
+ font-weight: 500 !important;
+ box-shadow:
+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
+ 0 4px 6px -4px rgb(0 0 0 / 0.1) !important;
+ z-index: 50 !important;
+ max-width: 20rem !important;
+ word-wrap: break-word !important;
+}
+
+/* Arrow styling */
+.tooltip-arrow:before {
+ background-color: inherit !important;
+}
+
+/* ==========================================================================
+ PRINT STYLES FOR LABELS (GLOBAL)
+ ========================================================================== */
+
+@media print {
+ @page {
+ size: 4in 6in;
+ margin: 0;
+ }
+
+ /* Hide everything */
+ body * {
+ visibility: hidden !important;
+ }
+
+ /* Remove parent containers completely from layout */
+ body > *:not(.print-label) {
+ display: none !important;
+ }
+
+ /* Make the print label the only element positioned */
+ .print-label, .print-label * {
+ visibility: visible !important;
+ }
+
+ .print-label {
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+
+ width: 4in !important;
+ height: 6in !important;
+ padding: 0.15in !important;
+
+ background: white !important;
+ border: 2px solid black !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ }
+
+ /* Prevent Tailwind layout constraints */
+ html, body {
+ width: 100% !important;
+ height: auto !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ background: white !important;
+ overflow: visible !important;
+ }
+}
+
+@property --border-angle {
+ syntax: '
';
+ inherits: false;
+ initial-value: 0deg;
+}
+
+@keyframes spin-border {
+ to { --border-angle: 360deg; }
+}
+
+@keyframes coach-fade-out {
+ 0%, 80% { opacity: 1; transform: translateY(0); }
+ 100% { opacity: 0; transform: translateY(-4px); pointer-events: none; }
+}
+
+.rainbow-spin-border {
+ position: relative;
+ border-radius: 9999px;
+ padding: 2px;
+ background: conic-gradient(
+ from var(--border-angle),
+ #ef4444, #eab308, #22c55e, #6366f1, #ef4444
+ );
+ animation: spin-border 2s linear infinite;
+}
+
+.rainbow-spin-border-inner {
+ border-radius: 9999px;
+ width: 100%;
+ height: 100%;
+}
+
+.coach-mark {
+ animation: coach-fade-out 4s ease-in-out forwards;
+ animation-delay: 1s;
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/layout.tsx b/src/vertex-template/app/layout.tsx
new file mode 100644
index 0000000000..f62aa6bab3
--- /dev/null
+++ b/src/vertex-template/app/layout.tsx
@@ -0,0 +1,92 @@
+import type React from 'react';
+import type { Metadata } from 'next';
+import './globals.css';
+import ClientLayout from './client-layout';
+import { Inter } from 'next/font/google';
+import { getServerSession } from "next-auth/next";
+import { options } from "@/app/api/auth/[...nextauth]/options";
+import logger from '@/lib/logger';
+import { vertexConfig } from '@/vertex.config';
+
+const inter = Inter({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-inter',
+});
+
+export const metadata: Metadata = {
+ title: {
+ template: `%s | ${vertexConfig.org.name}`,
+ default: vertexConfig.org.name,
+ },
+ description: vertexConfig.org.name + " is a leading air quality monitoring platform.",
+ keywords: [
+ 'air quality',
+ 'monitoring',
+ 'analytics',
+ 'environment',
+ 'data',
+ 'management',
+ 'device',
+ ],
+ authors: [{ name: `${vertexConfig.org.shortName} Team` }],
+ creator: vertexConfig.org.name,
+ publisher: vertexConfig.org.name,
+ openGraph: {
+ title: vertexConfig.org.name,
+ description: "Leading air quality device management platform",
+ type: 'website',
+ url: vertexConfig.org.websiteUrl,
+ images: [
+ {
+ url: vertexConfig.org.logo,
+ width: 1200,
+ height: 630,
+ alt: vertexConfig.org.name,
+ },
+ ],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: vertexConfig.org.name,
+ description: "Leading air quality device management platform",
+ images: ['/favicon.ico'],
+ },
+ icons: {
+ icon: '/favicon.ico',
+ apple: '/favicon.ico',
+ },
+};
+
+const hexToRgbValues = (hex: string) => {
+ const normalizedHex = hex.replace('#', '');
+ const r = parseInt(normalizedHex.slice(0, 2), 16);
+ const g = parseInt(normalizedHex.slice(2, 4), 16);
+ const b = parseInt(normalizedHex.slice(4, 6), 16);
+ return `${r} ${g} ${b}`;
+};
+
+export default async function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ let session = null;
+ try {
+ session = await getServerSession(options);
+ } catch (error) {
+ logger.error("Failed to fetch session:", { error });
+ }
+
+ const primaryRgb = hexToRgbValues(vertexConfig.org.primaryColor);
+
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/src/vertex-template/app/login/page.tsx b/src/vertex-template/app/login/page.tsx
new file mode 100644
index 0000000000..846ef749cd
--- /dev/null
+++ b/src/vertex-template/app/login/page.tsx
@@ -0,0 +1,407 @@
+"use client"
+import { CookieInfoBanner } from '@/components/features/auth/cookie-info-banner';
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod"
+import Link from "next/link"
+import Image from "next/image"
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import { useSearchParams } from "next/navigation";
+import { getSession, signIn } from "next-auth/react";
+import { Form, FormField } from "@/components/ui/form"
+import { signUpUrl, forgotPasswordUrl } from "@/core/urls"
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"
+import ReusableButton from "@/components/shared/button/ReusableButton"
+import { useBanner, BannerSlot } from "@/context/banner-context"
+import { HCaptchaWidget, type HCaptchaWidgetHandle } from "@/components/ui/hcaptcha-widget"
+import logger from "@/lib/logger"
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useAppDispatch } from "@/core/redux/hooks";
+import { isHCaptchaEnabled } from "@/lib/envConstants";
+import {
+ setLoggingOut,
+} from "@/core/redux/slices/userSlice";
+import { getLastActiveModule } from "@/core/utils/userPreferences";
+import { ROUTE_LINKS } from "@/core/routes";
+import SocialAuthSection from "@/components/features/auth/social-auth-section";
+import { motion, AnimatePresence } from "framer-motion";
+import { vertexConfig } from "@/vertex.config";
+
+
+const loginSchema = z.object({
+ userName: z.string().email({ message: "Please enter a valid email address" }),
+ password: z.string().min(8, { message: "Password must be at least 8 characters long" }),
+})
+
+export default function LoginPage() {
+ const { showBanner, hideBanner } = useBanner();
+ const [isLoading, setIsLoading] = useState(false)
+ const [step, setStep] = useState<'email' | 'password'>('email');
+ const [captchaToken, setCaptchaToken] = useState("");
+ const captchaRef = useRef(null);
+ const hcaptchaEnabled = useMemo(() => isHCaptchaEnabled(), []);
+ const searchParams = useSearchParams();
+ const callbackUrl = useMemo(() => {
+ const raw = searchParams.get("callbackUrl");
+ if (!raw) return "";
+ try {
+ const parsed = new URL(raw, window.location.origin);
+ if (parsed.origin !== window.location.origin) return "";
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+ } catch {
+ return "";
+ }
+ }, [searchParams]);
+ const waitForSession = useCallback(async () => {
+ const attempts = 8;
+ const delayMs = 150;
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ const session = await getSession();
+ if (session?.user) {
+ return session;
+ }
+
+ if (attempt < attempts - 1) {
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, delayMs);
+ });
+ }
+ }
+
+ return null;
+ }, []);
+
+ const form = useForm>({
+ resolver: zodResolver(loginSchema),
+ defaultValues: {
+ userName: "",
+ password: "",
+ },
+ })
+
+ const dispatch = useAppDispatch();
+ const isMounted = useRef(true);
+
+ const [platform, setPlatform] = useState<'win' | 'linux' | 'other' | null>(null);
+ const [isElectron, setIsElectron] = useState(false);
+
+ useEffect(() => {
+ isMounted.current = true;
+ // Reset logout state when login page mounts
+ dispatch(setLoggingOut(false));
+
+ // OS Detection for download link and platform check
+ const userAgent = window.navigator.userAgent.toLowerCase();
+ const isWin = userAgent.includes('win');
+ const isLinux = userAgent.includes('linux');
+ setIsElectron(userAgent.includes('electron'));
+
+ if (isWin) {
+ setPlatform('win');
+ } else if (isLinux) {
+ setPlatform('linux');
+ } else {
+ setPlatform('other');
+ }
+
+ const authError = searchParams.get('error');
+ if (authError === 'oauth_failed') {
+ showBanner({
+ severity: 'error',
+ message: 'Social sign-in failed or was cancelled. Please try again.',
+ scoped: true,
+ });
+ // Remove only the error flag while preserving callbackUrl and other safe params
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('error');
+ const nextUrl = params.toString() ? `/login?${params.toString()}` : '/login';
+ window.history.replaceState({}, '', nextUrl);
+ }
+
+ return () => {
+ isMounted.current = false;
+ };
+ }, [dispatch, searchParams, showBanner]);
+
+
+ const onSubmit = useCallback(async (values: z.infer) => {
+ // If we are on the email step, just validate the email and move forward
+ if (step === 'email') {
+ const isEmailValid = await form.trigger('userName');
+ if (isEmailValid) {
+ setStep('password');
+ }
+ return;
+ }
+
+ // On the password step, ensure password is also validated
+ const isPasswordValid = await form.trigger('password');
+ if (!isPasswordValid) return;
+
+ // If the user didn't provide the captchaToken
+ if (hcaptchaEnabled && captchaToken === "") {
+ showBanner({
+ severity: 'error',
+ message: 'Please complete the CAPTCHA before signing in.',
+ scoped: true
+ });
+ return;
+ }
+
+ setIsLoading(true);
+ // Record the login start time so the Home page can compute login duration
+ // for the post-login feedback toast. sessionStorage survives the redirect
+ // but is cleared automatically when the tab closes.
+ if (typeof window !== 'undefined') {
+ sessionStorage.setItem('vertex_login_start_ts', String(Date.now()));
+ }
+
+ // Read preference BEFORE authentication to avoid timing issues
+ const lastModule = getLastActiveModule(values.userName);
+ const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home';
+ const isAuthRouteCallback =
+ callbackUrl.startsWith('/login') ||
+ callbackUrl.startsWith('/auth-error') ||
+ callbackUrl.startsWith('/forgot-password');
+ const redirectUrl =
+ callbackUrl && !isAuthRouteCallback ? callbackUrl : fallbackUrl;
+
+ try {
+ const result = await signIn("credentials", {
+ redirect: false,
+ userName: values.userName,
+ password: values.password,
+ captchaToken: hcaptchaEnabled ? captchaToken : undefined,
+ callbackUrl: redirectUrl,
+ });
+
+ if (!isMounted.current) return;
+
+ if (result?.ok) {
+ const session = await waitForSession();
+ if (!session?.user) {
+ throw new Error("Could not confirm session. Please try again.");
+ }
+ showBanner({ severity: 'success', message: 'Welcome back!', scoped: true });
+
+ window.location.replace(result.url || redirectUrl);
+ } else {
+ let message = "Login failed. Please check your credentials.";
+ if (result?.error) {
+ if (result.error === 'CredentialsSignin') {
+ message = "Invalid email or password. Please check your credentials.";
+ } else if (result.error.toLowerCase().includes('fetch')) {
+ message = "Network error. Please check your connection and try again.";
+ } else {
+ message = result.error;
+ }
+ }
+ throw new Error(message);
+ }
+ } catch (error) {
+ if (!isMounted.current) return;
+ const message = getApiErrorMessage(error);
+ logger.error("Sign-in failed", { error: message });
+ showBanner({ severity: 'error', message, scoped: true });
+ setCaptchaToken("");
+ captchaRef.current?.reset();
+ setIsLoading(false);
+ }
+ }, [callbackUrl, waitForSession, step, form, showBanner, captchaToken, hcaptchaEnabled]);
+
+ return (
+
+ {/* Sticky Topbar */}
+
+
+ {/* Main Content Area */}
+
+
+
+
+
+ Deploy devices,
+ Share your data
+
+
+ Add your devices and stream live air quality data through {vertexConfig.org.name}'s open data channels.
+
+
+
+
+ {step === 'email' && (
+
+ )}
+
+
+
+
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/vertex-template/app/not-found.tsx b/src/vertex-template/app/not-found.tsx
new file mode 100644
index 0000000000..5883a0fc34
--- /dev/null
+++ b/src/vertex-template/app/not-found.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import React from 'react';
+import { useRouter } from 'next/navigation';
+import { useAppSelector } from '@/core/redux/hooks';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import OopsIcon from '@/public/icons/Errors/OopsIcon';
+
+/**
+ * 404 Not Found page component
+ * Automatically rendered by Next.js when a route is not found
+ */
+export default function NotFound() {
+ const router = useRouter();
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+
+ const handleGoHome = () => {
+ if (activeGroup) {
+ router.push('/home');
+ } else {
+ router.push('/');
+ }
+ };
+
+ return (
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Error Message */}
+
+ Oops! Page Not Found
+
+
+
+ The page you're looking for doesn't exist or has been moved.
+ Don't worry, let's get you back on track.
+
+
+ {/* Action Buttons */}
+
+
+ Go Home
+
+
+ window.history.back()}
+ >
+ Go Back
+
+
+
+ {/* Additional Help */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/page.tsx b/src/vertex-template/app/page.tsx
new file mode 100644
index 0000000000..ae2e8e482f
--- /dev/null
+++ b/src/vertex-template/app/page.tsx
@@ -0,0 +1,8 @@
+import { redirect } from 'next/navigation';
+
+export default function Page() {
+ // Fallback: This code should theoretically not be reached because middleware
+ // handles the routing for "/" before this page is rendered.
+ // We keep this as a safety net.
+ redirect('/home');
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/providers.tsx b/src/vertex-template/app/providers.tsx
new file mode 100644
index 0000000000..383d25a7a3
--- /dev/null
+++ b/src/vertex-template/app/providers.tsx
@@ -0,0 +1,63 @@
+"use client";
+import { useEffect, useMemo } from "react";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { persistor, store } from "@/core/redux/store";
+import { Provider } from "react-redux";
+import { PersistGate } from "redux-persist/integration/react";
+import { AuthProvider } from "@/core/auth/authProvider";
+import dynamic from 'next/dynamic';
+import { ThemeProvider } from "@/components/theme-provider";
+import SessionLoadingState from "@/components/layout/loading/session-loading";
+import { QueryProvider } from "@/core/providers/query-provider";
+import { runClientCacheMaintenance } from "@/core/utils/clientCache";
+import { BannerProvider } from "@/context/banner-context";
+
+const NetworkStatusBanner = dynamic(
+ () => import('@/components/features/network-status-banner'),
+ { ssr: false }
+);
+
+import { Session } from "next-auth";
+
+export default function Providers({ children, session }: { children: React.ReactNode, session: Session | null }) {
+ const cacheScope = useMemo(() => {
+ const user = session?.user as { id?: string; email?: string } | undefined;
+ const userId = typeof user?.id === "string" ? user.id.trim() : "";
+ if (userId) return `id:${userId}`;
+
+ const email =
+ typeof user?.email === "string" ? user.email.trim().toLowerCase() : "";
+ if (email) return `email:${email}`;
+
+ return "anon";
+ }, [session?.user]);
+
+ useEffect(() => {
+ runClientCacheMaintenance();
+ }, []);
+
+ return (
+
+ } persistor={persistor}>
+
+
+
+
+ {children}
+
+
+ {process.env.NODE_ENV !== "production" && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/app/types/clients.ts b/src/vertex-template/app/types/clients.ts
new file mode 100644
index 0000000000..88db634699
--- /dev/null
+++ b/src/vertex-template/app/types/clients.ts
@@ -0,0 +1,26 @@
+import { UserDetails } from './users';
+
+ export interface AccessToken {
+ _id: string
+ permissions: string[]
+ scopes: string[]
+ expiredEmailSent: boolean
+ token: string
+ client_id: string
+ name: string
+ expires: string
+ createdAt: string
+ updatedAt: string
+ __v: number
+ }
+
+ export interface Client {
+ _id: string
+ isActive: boolean
+ ip_addresses: string[]
+ name: string
+ client_secret: string
+ user: UserDetails
+ access_token: AccessToken
+ createdAt: string
+ }
diff --git a/src/vertex-template/app/types/cohorts.ts b/src/vertex-template/app/types/cohorts.ts
new file mode 100644
index 0000000000..77717c7276
--- /dev/null
+++ b/src/vertex-template/app/types/cohorts.ts
@@ -0,0 +1,106 @@
+export interface Cohort {
+ _id: string;
+ visibility: boolean;
+ cohort_tags: string[];
+ cohort_codes: string[];
+ name: string;
+ network: string;
+ groups: string[];
+ numberOfDevices: number;
+ devices: Device[];
+ createdAt?: string;
+}
+
+export interface CohortsSummaryResponse {
+ success: boolean;
+ message: string;
+ meta: {
+ total: number;
+ limit: number;
+ skip: number;
+ page: number;
+ totalPages: number;
+ };
+ cohorts: Cohort[];
+}
+
+export interface GroupCohortsResponse {
+ success: boolean;
+ message: string;
+ data: string[];
+}
+
+export interface PersonalUserCohortsResponse {
+ success: boolean;
+ message: string;
+ cohorts: string[];
+}
+
+export interface OriginalCohortResponse {
+ success: boolean;
+ message: string;
+ original_cohort: Cohort;
+}
+
+interface Grid {
+ _id: string;
+ visibility: boolean;
+ name: string;
+ admin_level: string;
+ network: string;
+ long_name: string;
+ createdAt: string;
+ sites: Site[];
+}
+
+interface Site {
+ _id: string;
+ isOnline: boolean;
+ formatted_name: string;
+ location_name: string;
+ search_name: string;
+ city: string;
+ district: string;
+ county: string;
+ region: string;
+ country: string;
+ latitude: number;
+ longitude: number;
+ name: string;
+ approximate_latitude: number;
+ approximate_longitude: number;
+ generated_name: string;
+ data_provider: string;
+ description: string;
+ site_category: SiteCategory;
+ groups: string[];
+ grids: Grid[];
+ devices: Device[];
+ airqlouds: unknown[];
+ createdAt: string;
+ updatedAt?: string;
+}
+
+interface SiteCategory {
+ tags: string[];
+ area_name: string;
+ category: string;
+ highway: string;
+ landuse: string;
+ latitude: number;
+ longitude: number;
+ natural: string;
+ search_radius: number;
+ waterway: string;
+ }
+
+interface Device {
+ _id: string;
+ name: string;
+ network: string;
+ groups: string[];
+ authRequired: boolean;
+ serial_number: string;
+ api_code: string;
+ long_name: string;
+}
\ No newline at end of file
diff --git a/src/vertex-template/app/types/devices.ts b/src/vertex-template/app/types/devices.ts
new file mode 100644
index 0000000000..6e8bf3fe2a
--- /dev/null
+++ b/src/vertex-template/app/types/devices.ts
@@ -0,0 +1,461 @@
+export interface DeviceSite {
+ _id: string;
+ visibility: boolean;
+ grids: string[];
+ isOnline: boolean;
+ location_name: string;
+ search_name: string;
+ group: string;
+ name: string;
+ data_provider: string;
+ site_category: {
+ tags: string[];
+ area_name: string;
+ category: string;
+ highway: string;
+ landuse: string;
+ latitude: number;
+ longitude: number;
+ natural: string;
+ search_radius: number;
+ waterway: string;
+ };
+ groups: string[];
+ description?: string;
+ createdAt?: string;
+}
+
+export interface DeviceGrid {
+ _id: string;
+ visibility: boolean;
+ name: string;
+ admin_level: string;
+ long_name: string;
+}
+
+export interface DevicePreviousSite {
+ _id: string;
+ visibility?: boolean;
+ rawOnlineStatus?: boolean;
+ lastRawData?: string | null;
+ name?: string;
+ location_name?: string;
+ search_name?: string;
+ [key: string]: unknown;
+}
+
+export interface DeviceCategoryHierarchy {
+ level: string;
+ category: string;
+ description: string;
+}
+
+export interface DeviceCategoryRelationships {
+ type: string;
+ note: string;
+ belongs_to_equipment_category: string;
+ deployment_method: string;
+}
+
+export interface DeviceCategories {
+ primary_category: string;
+ deployment_category: string;
+ is_mobile: boolean;
+ is_static: boolean;
+ is_lowcost: boolean;
+ is_bam: boolean;
+ is_gas: boolean;
+ all_categories: string[];
+ category_hierarchy: DeviceCategoryHierarchy[];
+ category_relationships: DeviceCategoryRelationships;
+}
+
+export interface Device {
+ _id?: string;
+ id?: string;
+ name: string;
+ alias?: string;
+ mobility?: boolean;
+ network: string;
+ groups?: string[];
+ serial_number: string;
+ authRequired?: boolean;
+ long_name?: string;
+ latitude?: number | undefined | null | string;
+ longitude?: number | undefined | null | string;
+ approximate_distance_in_km?: number;
+ bearing_in_radians?: number;
+ createdAt: string;
+ visibility?: boolean | undefined;
+ description?: string | undefined;
+ isPrimaryInLocation?: boolean;
+ nextMaintenance?: string;
+ deployment_date?: string;
+ mountType?: string;
+ isActive: boolean;
+ isOnline: boolean;
+ pictures?: unknown[];
+ site_id?: string;
+ host_id?: string | null;
+ height?: number;
+ device_codes: string[];
+ category: string;
+ device_categories?: DeviceCategories;
+ cohorts: unknown[];
+ device_number?: number | undefined | string;
+ readKey?: string;
+ writeKey?: string;
+ phoneNumber?: string;
+ generation_version?: number | undefined | string;
+ generation_count?: number | undefined | string;
+ previous_sites?: Array;
+ grids?: DeviceGrid[];
+ site?: DeviceSite[] | {
+ _id: string;
+ name: string;
+ };
+ status?: "not deployed" | "deployed" | "recalled" | "online" | "offline";
+ maintenance_status?: "good" | "due" | "overdue" | -1;
+ powerType?: "solar" | "alternator" | "mains";
+ elapsed_time?: number;
+ // Additional properties for device ownership and status
+ owner_id?: string;
+ assigned_organization_id?: string;
+ claim_status?: "claimed" | "unclaimed";
+ claimed_at?: string;
+ site_name?: string; // Optional site name for display purposes
+ [key: string]: unknown;
+ onlineStatusAccuracy?: {
+ accuracyPercentage?: number;
+ correctChecks?: number;
+ failurePercentage?: number;
+ lastCheck?: string;
+ lastCorrectCheck?: string;
+ lastSuccessfulUpdate?: string;
+ lastUpdate?: string;
+ successPercentage?: number;
+ successfulUpdates?: number;
+ totalAttempts?: number;
+ totalChecks?: number;
+ failedUpdates?: number;
+ incorrectChecks?: number;
+ lastFailureReason?: string;
+ lastIncorrectCheck?: string;
+ lastIncorrectReason?: string;
+ };
+ api_code?: string;
+ lastActive?: string;
+ lastRawData?: string;
+ rawOnlineStatus?: boolean;
+ tags?: string[];
+}
+
+export interface PaginationMeta {
+ total: number;
+ totalResults: number;
+ limit: number;
+ skip: number;
+ page: number;
+ totalPages: number;
+ detailLevel: string;
+ usedCache: boolean;
+}
+
+export interface DevicesSummaryResponse {
+ success: boolean;
+ message: string;
+ devices: Device[];
+ meta: PaginationMeta;
+ cache_generated_at?: string;
+}
+
+export interface DeviceAvailabilityResponse {
+ success: boolean;
+ message: string;
+ data: {
+ available: boolean;
+ status: "unclaimed" | "claimed" | "deployed";
+ };
+}
+
+export interface DeviceClaimRequest {
+ device_name: string;
+ user_id: string;
+ claim_token?: string;
+ cohort_id?: string;
+}
+
+export interface DeviceClaimResponse {
+ success: boolean;
+ message: string;
+ device: {
+ name: string;
+ long_name: string;
+ status: string;
+ claim_status: "claimed";
+ claimed_at: string;
+ };
+}
+
+export interface BulkDeviceClaimItem {
+ device_name: string;
+ claim_token: string;
+}
+
+export interface BulkDeviceClaimRequest {
+ user_id: string;
+ devices: BulkDeviceClaimItem[];
+ cohort_id?: string;
+}
+
+export interface BulkDeviceClaimResult {
+ device_name: string;
+ success?: boolean;
+ device?: {
+ name: string;
+ long_name: string;
+ status: string;
+ claim_status: "claimed";
+ claimed_at: string;
+ };
+ error?: string;
+}
+
+export interface BulkDeviceClaimResponse {
+ success?: boolean;
+ message: string;
+ data: {
+ successful_claims: BulkDeviceClaimResult[];
+ failed_claims: BulkDeviceClaimResult[];
+ };
+}
+
+export interface MyDevicesResponse {
+ success: boolean;
+ message: string;
+ devices: Device[];
+ total_devices: number;
+ deployed_devices: number;
+ deployed_devices_count?: number;
+}
+
+export interface DeviceAssignmentRequest {
+ device_name: string;
+ organization_id: string;
+ user_id: string;
+}
+
+export interface DeviceAssignmentResponse {
+ success: boolean;
+ message: string;
+ device: Device;
+}
+
+export interface DeviceCreationResponse {
+ success: boolean;
+ message: string;
+ created_device: Device;
+}
+
+export interface BulkImportDeviceResult {
+ serial_number: string;
+ long_name: string;
+ success: boolean;
+ created_device?: {
+ _id: string;
+ name: string;
+ network: string;
+ };
+ error?: string;
+}
+
+export interface BulkImportDeviceResponse {
+ success: boolean;
+ message: string;
+ imported: number;
+ failed: number;
+ total: number;
+ results: BulkImportDeviceResult[];
+}
+
+export interface DeviceUpdateGroupResponse {
+ success: boolean;
+ message: string;
+ updated_device?: Device;
+}
+
+export interface MaintenanceLogData {
+ date: string;
+ tags: string[];
+ description: string;
+ userName: string;
+ maintenanceType: "preventive" | "corrective";
+ email: string;
+ firstName: string;
+ lastName: string;
+ user_id: string;
+}
+
+export interface DecryptionRequest {
+ encrypted_key: string;
+ device_number: number;
+}
+
+export interface DecryptedKeyResult {
+ encrypted_key: string;
+ device_number: string;
+ decrypted_key: string;
+}
+
+export interface DecryptionResponse {
+ success: boolean;
+ message: string;
+ decrypted_keys: DecryptedKeyResult[];
+}
+
+export interface DevicePreparation {
+ device_name: string;
+ claim_token: string;
+ token_type: string;
+ qr_code_data: {
+ device_id: string;
+ claim_url: string;
+ platform: string;
+ token: string;
+ generated_at: string;
+ };
+ qr_code_image: string;
+ label_data: {
+ device_name: string;
+ device_id: string;
+ claim_token: string;
+ instructions: string[];
+ };
+ shipping_prepared_at: string;
+}
+
+export interface PrepareDeviceResponse {
+ success: boolean;
+ message: string;
+ device_preparation: DevicePreparation;
+}
+
+export interface BulkPreparationResult {
+ device_name: string;
+ claim_token: string;
+ qr_code_data: unknown;
+ qr_code_image: string;
+}
+
+export interface BulkPreparationFailure {
+ device_name: string;
+ error: string;
+}
+
+export interface BulkPrepareResponse {
+ success: boolean;
+ message: string;
+ bulk_preparation_results: {
+ successful_preparations: BulkPreparationResult[];
+ failed_preparations: BulkPreparationFailure[];
+ summary: {
+ total_requested: number;
+ successful_count: number;
+ failed_count: number;
+ };
+ };
+}
+
+export interface ShippingLabel {
+ device_name: string;
+ device_id: string;
+ device_long_name: string;
+ claim_token: string;
+ qr_code_image: string;
+ qr_code_data: unknown;
+ instructions: string[];
+}
+
+export interface GenerateLabelsResponse {
+ success: boolean;
+ message: string;
+ shipping_labels: {
+ labels: ShippingLabel[];
+ total_labels: number;
+ };
+}
+
+export interface ShippingStatusDevice {
+ id?: string;
+ _id?: string;
+ name: string;
+ long_name: string;
+ claim_status: string;
+ claim_token: string | null;
+ shipping_prepared_at: string;
+ owner_id: string;
+ claimed_at: string;
+}
+
+export interface ShippingStatusResponse {
+ success: boolean;
+ message: string;
+ shipping_status: {
+ devices: ShippingStatusDevice[];
+ summary: {
+ total_devices: number;
+ prepared_for_shipping: number;
+ claimed_devices: number;
+ deployed_devices: number;
+ };
+ categorized: {
+ prepared_for_shipping: unknown[];
+ claimed_devices: unknown[];
+ deployed_devices: unknown[];
+ };
+ };
+}
+
+export interface OrphanedDevice {
+ _id: string;
+ name: string;
+ long_name: string;
+ status: string;
+ isActive: boolean;
+ claim_status: string;
+ owner_id: string;
+ cohort_ids: string[];
+ claimed_at: string;
+}
+
+export interface OrphanedDevicesResponse {
+ success: boolean;
+ message: string;
+ devices: OrphanedDevice[];
+ total_orphaned: number;
+ recommendation: string;
+}
+
+export interface ShippingBatch {
+ _id: string;
+ batch_name: string;
+ device_count: number;
+ device_names: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ShippingBatchesResponse {
+ success: boolean;
+ message: string;
+ batches: ShippingBatch[];
+ meta: PaginationMeta;
+}
+
+export interface ShippingBatchDetailsResponse {
+ success: boolean;
+ message: string;
+ batch: ShippingBatch & {
+ devices: ShippingStatusDevice[];
+ };
+}
diff --git a/src/vertex-template/app/types/export.ts b/src/vertex-template/app/types/export.ts
new file mode 100644
index 0000000000..f55a618ba2
--- /dev/null
+++ b/src/vertex-template/app/types/export.ts
@@ -0,0 +1,17 @@
+import { Option } from "@/components/ui/multi-select"
+
+export type ExportType = 'sites' | 'devices' | 'airqlouds' | 'regions'
+
+export interface FormData {
+ startDateTime: string;
+ endDateTime: string;
+ sites?: Option[];
+ devices?: Option[];
+ cities?: Option[];
+ regions?: Option[];
+ pollutants?: Option[];
+ frequency: string
+ fileType: string
+ outputFormat: string
+ dataType: string
+}
diff --git a/src/vertex-template/app/types/grids.ts b/src/vertex-template/app/types/grids.ts
new file mode 100644
index 0000000000..b918fd882c
--- /dev/null
+++ b/src/vertex-template/app/types/grids.ts
@@ -0,0 +1,40 @@
+import { Position } from "@/core/redux/slices/gridsSlice";
+import { Site } from "./sites";
+import { Group } from "./groups";
+
+export interface CreateGrid {
+ name: string;
+ admin_level: string;
+ shape: {
+ type: "MultiPolygon" | "Polygon";
+ coordinates: Position[][] | Position[][][];
+ };
+ network: string;
+}
+
+export interface Grid {
+ _id: string;
+ visibility: boolean;
+ name: string;
+ admin_level: string;
+ network: string;
+ long_name: string;
+ createdAt: string;
+ sites: Site[];
+ numberOfSites: number;
+ groups?: Group[];
+}
+
+export interface GridsSummaryResponse {
+ success: boolean;
+ message: string;
+ meta: {
+ total: number;
+ limit: number;
+ skip: number;
+ page: number;
+ totalPages: number;
+ nextPage?: string;
+ };
+ grids: Grid[];
+}
diff --git a/src/vertex-template/app/types/groups.ts b/src/vertex-template/app/types/groups.ts
new file mode 100644
index 0000000000..25516da195
--- /dev/null
+++ b/src/vertex-template/app/types/groups.ts
@@ -0,0 +1,29 @@
+import { UserDetails } from "@/app/types/users";
+
+export interface Group {
+ _id: string
+ grp_status: "ACTIVE" | "INACTIVE"
+ grp_profile_picture: string
+ grp_title: string
+ grp_description: string
+ grp_website: string
+ grp_industry: string
+ grp_country: string
+ grp_timezone: string
+ createdAt: string
+ numberOfGroupUsers: number
+ grp_users: UserDetails[]
+ grp_manager: UserDetails
+ cohorts?: string[]
+ organization_slug?: string
+ onboarding_checklist?: {
+ is_dismissed: boolean;
+ completed_steps: string[];
+ }
+}
+
+export interface CohortGroupsResponse {
+ success: boolean;
+ message: string;
+ groups: Group[];
+}
diff --git a/src/vertex-template/app/types/layout.ts b/src/vertex-template/app/types/layout.ts
new file mode 100644
index 0000000000..8bfcf6e828
--- /dev/null
+++ b/src/vertex-template/app/types/layout.ts
@@ -0,0 +1,5 @@
+import { ReactNode } from "react";
+
+export interface LayoutProps {
+ children: ReactNode;
+}
diff --git a/src/vertex-template/app/types/roles.ts b/src/vertex-template/app/types/roles.ts
new file mode 100644
index 0000000000..76a1dc4b74
--- /dev/null
+++ b/src/vertex-template/app/types/roles.ts
@@ -0,0 +1,39 @@
+export interface Permission {
+ _id: string;
+ permission: string;
+ description: string;
+ }
+
+export interface Group {
+ _id: string;
+ grp_status: string;
+ grp_profile_picture: string;
+ grp_title: string;
+ grp_description: string;
+ grp_website: string;
+ grp_industry: string;
+ grp_country: string;
+ grp_timezone: string;
+ grp_manager: string;
+ grp_manager_username: string;
+ grp_manager_firstname: string;
+ grp_manager_lastname: string;
+ createdAt: string;
+ updatedAt: string;
+ __v: number;
+ }
+
+export interface Role {
+ _id: string;
+ role_status: string;
+ role_name: string;
+ role_permissions: Permission[];
+ group?: Group;
+ }
+
+export interface RolesResponse {
+ success: boolean;
+ message: string;
+ roles: Role[];
+ }
+
\ No newline at end of file
diff --git a/src/vertex-template/app/types/sites.ts b/src/vertex-template/app/types/sites.ts
new file mode 100644
index 0000000000..7cbd22e0a1
--- /dev/null
+++ b/src/vertex-template/app/types/sites.ts
@@ -0,0 +1,104 @@
+import {Device as DeviceType} from "./devices";
+
+export interface Site {
+ _id?: string;
+ nearest_tahmo_station?: {
+ id: number;
+ code: string | null;
+ longitude: number;
+ latitude: number;
+ timezone: string | null;
+ };
+ images?: unknown[];
+ groups?: string[];
+ site_codes?: string[];
+ site_tags?: string[];
+ isOnline?: boolean;
+ rawOnlineStatus?: boolean;
+ formatted_name?: string;
+ location_name?: string;
+ search_name?: string;
+ parish?: string;
+ village?: string;
+ sub_county?: string;
+ city?: string;
+ district?: string;
+ county?: string;
+ region?: string;
+ country?: string;
+ latitude?: number;
+ longitude?: number;
+ name?: string;
+ network?: string;
+ approximate_latitude?: number;
+ approximate_longitude?: number;
+ bearing_in_radians?: number;
+ approximate_distance_in_km?: number;
+ lat_long?: string;
+ generated_name?: string;
+ altitude?: number;
+ data_provider?: string;
+ description?: string;
+ weather_stations?: Array<{
+ code: string;
+ name: string;
+ country: string;
+ longitude: number;
+ latitude: number;
+ timezone: string;
+ distance: number;
+ _id: string;
+ }>;
+ createdAt?: string;
+ lastActive?: string;
+ grids?: Array<{
+ _id: string;
+ name: string;
+ admin_level: string;
+ visibility: boolean;
+ }>;
+ devices?: DeviceType[];
+ airqlouds?: unknown[];
+ site_category?: {
+ tags: string[];
+ area_name: string;
+ category: string;
+ highway: string;
+ landuse: string;
+ latitude: number;
+ longitude: number;
+ natural: string;
+ search_radius: number;
+ waterway: string;
+ };
+ geometry?: {
+ bounds: {
+ northeast: { lat: number; lng: number };
+ southwest: { lat: number; lng: number };
+ };
+ location: { lat: number; lng: number };
+ location_type: string;
+ viewport: {
+ northeast: { lat: number; lng: number };
+ southwest: { lat: number; lng: number };
+ };
+ };
+ landform_270?: number;
+ landform_90?: number;
+ aspect?: number;
+ distance_to_nearest_road?: number;
+ distance_to_nearest_secondary_road?: number;
+ distance_to_nearest_unclassified_road?: number;
+ distance_to_nearest_residential_road?: number;
+ share_links?: Record;
+}
+
+export interface SiteDevice {
+ name: string;
+ description?: string;
+ site?: string;
+ isPrimary: boolean;
+ isCoLocated: boolean;
+ registrationDate: string;
+ deploymentStatus: "Deployed" | "Pending" | "Removed";
+}
diff --git a/src/vertex-template/app/types/userStats.ts b/src/vertex-template/app/types/userStats.ts
new file mode 100644
index 0000000000..131cbda9b3
--- /dev/null
+++ b/src/vertex-template/app/types/userStats.ts
@@ -0,0 +1,25 @@
+export interface User {
+ userName?: string
+ email?: string
+ firstName?: string
+ lastName?: string
+ _id?: string
+ }
+
+ export interface UserCategory {
+ number: number
+ details: User[]
+ }
+
+ export interface UserStats {
+ users: UserCategory
+ active_users: UserCategory
+ api_users: UserCategory
+ }
+
+ export interface UserStatsResponse {
+ success: boolean
+ message: string
+ users_stats: UserStats
+ }
+
\ No newline at end of file
diff --git a/src/vertex-template/app/types/users.ts b/src/vertex-template/app/types/users.ts
new file mode 100644
index 0000000000..6a49064b22
--- /dev/null
+++ b/src/vertex-template/app/types/users.ts
@@ -0,0 +1,130 @@
+export interface Permission {
+ _id: string;
+ permission: string;
+ network_id?: string;
+ description?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface Role {
+ _id: string;
+ role_name: string;
+ role_permissions: Permission[];
+}
+
+export interface Network {
+ net_name: string;
+ _id: string;
+ role: Role;
+ userType: string;
+ createdAt?: string;
+ status?: string;
+}
+
+export interface Client {
+ _id: string;
+ name: string;
+ user_id: string;
+ client_secret: string;
+ createdAt: string;
+ updatedAt: string;
+ isActive: boolean;
+}
+
+export interface Group {
+ grp_title: string;
+ _id: string;
+ createdAt: string;
+ status: string;
+ role: Role;
+ userType: string;
+ onboarding_checklist?: {
+ is_dismissed: boolean;
+ completed_steps: string[];
+ };
+}
+
+export interface UserDetails {
+ _id: string;
+ firstName: string;
+ lastName: string;
+ lastLogin: string;
+ isActive?: boolean;
+ loginCount?: number;
+ userName: string;
+ email: string;
+ verified?: boolean;
+ analyticsVersion?: number;
+ country?: string | null;
+ privilege?: string;
+ website?: string | null;
+ category?: string | null;
+ organization?: string;
+ long_organization?: string;
+ rateLimit: number | null;
+ jobTitle?: string | null;
+ description?: string | null;
+ timezone?: string | null;
+ profilePicture: string | null;
+ phoneNumber: string | null;
+ updatedAt: string;
+ networks?: Network[];
+ clients?: Client[];
+ groups?: Group[];
+ permissions?: Permission[];
+ createdAt: string;
+ my_networks?: string[];
+ my_groups?: string[];
+ group_ids?: string[];
+ cohort_ids?: string[];
+ iat?: number;
+}
+
+export interface LoginResponse {
+ success: boolean;
+ message: string;
+ token: string;
+ _id: string;
+ userName: string;
+ email: string;
+}
+
+export interface UserDetailsResponse {
+ success: boolean;
+ message: string;
+ users: UserDetails[];
+}
+
+export interface LoginCredentials {
+ userName: string;
+ password: string;
+ captchaToken?: string;
+}
+
+export interface DecodedToken {
+ _id: string;
+ firstName: string;
+ lastName: string;
+ userName: string;
+ email: string;
+ organization: string;
+ long_organization: string;
+ privilege: string;
+ country: string | null;
+ profilePicture: string | null;
+ description: string | null;
+ timezone: string | null;
+ phoneNumber: string | null;
+ createdAt: string;
+ updatedAt: string;
+ rateLimit: number | null;
+ lastLogin: string;
+ iat: number;
+ exp?: number;
+}
+
+export interface CurrentRole {
+ role_name: string;
+ permissions: string[];
+}
diff --git a/src/vertex-template/components.json b/src/vertex-template/components.json
new file mode 100644
index 0000000000..a2a87a40b0
--- /dev/null
+++ b/src/vertex-template/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "core": "@/core"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/src/vertex-template/components/features/auth/cookie-info-banner.tsx b/src/vertex-template/components/features/auth/cookie-info-banner.tsx
new file mode 100644
index 0000000000..3c67976380
--- /dev/null
+++ b/src/vertex-template/components/features/auth/cookie-info-banner.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { COOKIE_POLICY_URL } from '@/lib/envConstants';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+
+export function CookieInfoBanner() {
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const cookiesAccepted = localStorage.getItem('vertex_cookies_accepted');
+ if (!cookiesAccepted) {
+ setIsVisible(true);
+ }
+ }, []);
+
+ const handleDismiss = () => {
+ localStorage.setItem('vertex_cookies_accepted', 'true');
+ setIsVisible(false);
+ };
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+
+
+ AirQo uses cookies to deliver and enhance the quality of its services and to analyze traffic.
+ {' '}
+
+ Learn more about our cookie policy
+
+
+
+ OK, got it
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/auth/social-auth-section.tsx b/src/vertex-template/components/features/auth/social-auth-section.tsx
new file mode 100644
index 0000000000..f00f7c115c
--- /dev/null
+++ b/src/vertex-template/components/features/auth/social-auth-section.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { FaGithub, FaLinkedinIn } from 'react-icons/fa';
+import { FaXTwitter } from 'react-icons/fa6';
+import { FcGoogle } from 'react-icons/fc';
+import {
+ buildOAuthInitiationUrl,
+ getLastUsedOAuthProvider,
+ resolveOAuthRedirectAfterUrl,
+ setLastUsedOAuthProvider,
+ type SupportedSocialAuthProvider,
+} from '@/core/auth/oauth-session';
+import { cn } from '@/lib/utils';
+import { useBanner } from '@/context/banner-context';
+import { getLastActiveModule } from '@/core/utils/userPreferences';
+
+interface SocialAuthSectionProps {
+ mode: 'login' | 'register';
+ disabled?: boolean;
+ className?: string;
+ callbackUrl?: string | null;
+}
+
+const SOCIAL_PROVIDERS: Array<{
+ provider: SupportedSocialAuthProvider;
+ label: string;
+ Icon: React.ComponentType<{ className?: string }>;
+ iconClassName?: string;
+}> = [
+ {
+ provider: 'google',
+ label: 'Google',
+ Icon: FcGoogle,
+ },
+ {
+ provider: 'github',
+ label: 'GitHub',
+ Icon: FaGithub,
+ iconClassName: 'text-slate-950 dark:text-white',
+ },
+ {
+ provider: 'linkedin',
+ label: 'LinkedIn',
+ Icon: FaLinkedinIn,
+ iconClassName: 'text-[#0A66C2]',
+ },
+ {
+ provider: 'twitter',
+ label: 'X',
+ Icon: FaXTwitter,
+ iconClassName: 'text-slate-950 dark:text-white',
+ },
+];
+
+export default function SocialAuthSection({
+ mode,
+ disabled = false,
+ className,
+ callbackUrl,
+}: SocialAuthSectionProps) {
+ const { showBanner } = useBanner();
+ const actionLabel = mode === 'register' ? 'Continue with' : 'Sign in with';
+ const lastModule = getLastActiveModule();
+ const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home';
+ const redirectPath = callbackUrl || fallbackUrl;
+ const [lastUsedProvider, setLastUsedProvider] =
+ useState(null);
+
+ useEffect(() => {
+ setLastUsedProvider(getLastUsedOAuthProvider());
+ }, []);
+
+ const orderedProviders = lastUsedProvider
+ ? [
+ ...SOCIAL_PROVIDERS.filter(
+ ({ provider }) => provider === lastUsedProvider
+ ),
+ ...SOCIAL_PROVIDERS.filter(
+ ({ provider }) => provider !== lastUsedProvider
+ ),
+ ]
+ : SOCIAL_PROVIDERS;
+
+ const handleSocialAuth = useCallback(
+ (provider: SupportedSocialAuthProvider) => {
+ if (typeof window === 'undefined' || disabled) return;
+
+ const redirectAfter = resolveOAuthRedirectAfterUrl(redirectPath);
+ const queryParams: Record = {};
+
+ if (redirectAfter) {
+ queryParams.redirect_after = redirectAfter;
+ }
+
+
+ try {
+ setLastUsedOAuthProvider(provider);
+ window.location.replace(buildOAuthInitiationUrl(provider, queryParams));
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ message: `Social sign-in unavailable. Please try again in a moment.`,
+ scoped: true,
+ });
+ console.error(`Failed to start ${provider} OAuth flow:`, error);
+ }
+ },
+ [disabled, redirectPath, showBanner]
+ );
+
+ return (
+
+
+ {orderedProviders.map(({ provider, label, Icon, iconClassName }) => {
+ const isLastUsed = provider === lastUsedProvider;
+
+ return (
+ handleSocialAuth(provider)}
+ aria-label={`${actionLabel} ${label}`}
+ title={`${actionLabel} ${label}`}
+ className={cn(
+ 'relative flex h-10 items-center justify-center overflow-hidden rounded-md border bg-white px-1.5 text-gray-900 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-white disabled:text-gray-400 dark:bg-transparent dark:text-gray-100 dark:disabled:border-slate-700 dark:disabled:bg-transparent dark:disabled:text-gray-500',
+ isLastUsed
+ ? 'border-primary/50 bg-primary/5 hover:bg-primary/10 dark:border-primary/50 dark:bg-slate-900'
+ : 'border-slate-300 hover:bg-slate-50 hover:text-gray-900 dark:border-slate-700 dark:hover:bg-slate-800 dark:hover:text-gray-100'
+ )}
+ >
+
+ {isLastUsed ? (
+
+ Last used
+
+ ) : null}
+ {`${actionLabel} ${label}`}
+
+ );
+ })}
+
+
+
+
+
+ Or
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/claim/DeviceEntryRow.tsx b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx
new file mode 100644
index 0000000000..d991fde523
--- /dev/null
+++ b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import React from 'react';
+import { AqXClose } from '@airqo/icons-react';
+import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
+
+interface DeviceEntryRowProps {
+ deviceName: string;
+ claimToken: string;
+ onDeviceNameChange: (value: string) => void;
+ onClaimTokenChange: (value: string) => void;
+ onRemove: () => void;
+ deviceNameError?: string;
+ claimTokenError?: string;
+ index: number;
+ showRemove?: boolean;
+}
+
+export const DeviceEntryRow: React.FC = ({
+ deviceName,
+ claimToken,
+ onDeviceNameChange,
+ onClaimTokenChange,
+ onRemove,
+ deviceNameError,
+ claimTokenError,
+ index,
+ showRemove = true,
+}) => {
+ return (
+
+
+
+ {index + 1}
+
+
+
+ onDeviceNameChange(e.target.value)}
+ error={deviceNameError}
+ required
+ />
+ onClaimTokenChange(e.target.value)}
+ error={claimTokenError}
+ required
+ />
+
+ {showRemove && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/vertex-template/components/features/claim/FileUploadParser.tsx b/src/vertex-template/components/features/claim/FileUploadParser.tsx
new file mode 100644
index 0000000000..a6cb358057
--- /dev/null
+++ b/src/vertex-template/components/features/claim/FileUploadParser.tsx
@@ -0,0 +1,352 @@
+'use client';
+
+import React, { useRef, useState } from 'react';
+import { AqUploadCloud02 } from '@airqo/icons-react';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import ReusableToast from '@/components/shared/toast/ReusableToast';
+
+export interface ParsedFileData {
+ headers: string[];
+ data: unknown[][];
+ fileName: string;
+}
+
+interface BulkClaimColumnMapperProps {
+ filePreview: ParsedFileData;
+ onConfirm: (deviceNameColumn: number, claimTokenColumn: number) => void;
+ onCancel: () => void;
+}
+
+export const BulkClaimColumnMapper: React.FC = ({
+ filePreview,
+ onConfirm,
+ onCancel,
+}) => {
+ const [deviceNameColumn, setDeviceNameColumn] = useState(() => {
+ // Auto-detect device name column
+ const index = filePreview.headers.findIndex(h =>
+ /device.*name|name|device.*id|device/i.test(h.toLowerCase())
+ );
+ return index !== -1 ? index : 0;
+ });
+
+ const [claimTokenColumn, setClaimTokenColumn] = useState(() => {
+ // Auto-detect claim token column
+ const index = filePreview.headers.findIndex(h =>
+ /claim.*token|token|claim/i.test(h.toLowerCase())
+ );
+ return index !== -1 ? index : filePreview.headers.length > 1 ? 1 : 0;
+ });
+
+ const handleConfirm = () => {
+ if (deviceNameColumn === claimTokenColumn) {
+ ReusableToast({
+ message: 'Device name and claim token must be in different columns',
+ type: 'ERROR',
+ });
+ return;
+ }
+ onConfirm(deviceNameColumn, claimTokenColumn);
+ };
+
+ return (
+
+
+
+
+ Map Columns for Device Claiming
+
+
+ File: {filePreview.fileName}
+
+
+
+
+
+ Please select which columns contain the device names and claim tokens:
+
+
+ {/* Column Selectors */}
+
+
+
+ Device Name Column
+
+ setDeviceNameColumn(Number(e.target.value))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary"
+ >
+ {filePreview.headers.map((header, index) => (
+
+ {header}
+
+ ))}
+
+
+
+
+ Claim Token Column
+
+ setClaimTokenColumn(Number(e.target.value))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary"
+ >
+ {filePreview.headers.map((header, index) => (
+
+ {header}
+
+ ))}
+
+
+
+
+ {/* Preview Table */}
+
+
+
+
+ {filePreview.headers.map((header, index) => (
+
+ {header}
+ {index === deviceNameColumn && (
+ (Device)
+ )}
+ {index === claimTokenColumn && (
+ (Token)
+ )}
+
+ ))}
+
+
+
+ {filePreview.data.slice(1, 6).map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+ {String(cell || '')}
+
+ ))}
+
+ ))}
+
+
+
+
+ Showing first 5 rows as preview
+
+
+
+
+
+ Cancel
+
+ Import Devices
+
+
+
+ );
+};
+
+interface FileUploadParserProps {
+ onFilesParsed: (devices: Array<{ device_name: string; claim_token: string }>) => void;
+ variant?: 'button' | 'dropzone';
+}
+
+export const FileUploadParser: React.FC = ({ onFilesParsed, variant = 'button' }) => {
+ const [isImporting, setIsImporting] = useState(false);
+ const [filePreview, setFilePreview] = useState(null);
+ const fileInputRef = useRef(null);
+
+ const handleFileImport = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
+ if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) {
+ ReusableToast({
+ message: 'Invalid file format. Please upload a CSV or Excel file.',
+ type: 'ERROR',
+ });
+ e.target.value = '';
+ return;
+ }
+
+ if (file.size > 5 * 1024 * 1024) {
+ ReusableToast({ message: 'File too large. Maximum size is 5MB.', type: 'ERROR' });
+ e.target.value = '';
+ return;
+ }
+
+ setIsImporting(true);
+
+ try {
+ let parsedData: unknown[][] = [];
+ let headers: string[] = [];
+
+ if (fileExtension === 'csv') {
+ const Papa = (await import('papaparse')).default;
+ Papa.parse(file, {
+ complete: (results) => {
+ parsedData = results.data as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ }
+ setIsImporting(false);
+ },
+ error: (error) => {
+ ReusableToast({
+ message: `Error parsing CSV: ${error.message}`,
+ type: 'ERROR',
+ });
+ setIsImporting(false);
+ },
+ });
+ } else {
+ const XLSX = await import('xlsx');
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ const bstr = evt.target?.result;
+ const workbook = XLSX.read(bstr, { type: 'binary' });
+ const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
+ parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ }
+ setIsImporting(false);
+ };
+ reader.onerror = () => {
+ ReusableToast({ message: 'Error reading Excel file', type: 'ERROR' });
+ setIsImporting(false);
+ };
+ reader.readAsBinaryString(file);
+ }
+ } catch {
+ ReusableToast({
+ message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.',
+ type: 'ERROR',
+ });
+ setIsImporting(false);
+ }
+
+ e.target.value = '';
+ };
+
+ const handleConfirmImport = (deviceNameColumn: number, claimTokenColumn: number) => {
+ if (!filePreview) return;
+
+ const devices = filePreview.data
+ .slice(1)
+ .map((row) => {
+ const deviceName = row[deviceNameColumn];
+ const claimToken = row[claimTokenColumn];
+ return {
+ device_name:
+ typeof deviceName === 'string'
+ ? deviceName.trim()
+ : String(deviceName || '').trim(),
+ claim_token:
+ typeof claimToken === 'string'
+ ? claimToken.trim()
+ : String(claimToken || '').trim(),
+ };
+ })
+ .filter((device) => device.device_name.length > 0 && device.claim_token.length > 0);
+
+ if (devices.length === 0) {
+ ReusableToast({
+ message: 'No valid devices found in the selected columns',
+ type: 'ERROR',
+ });
+ return;
+ }
+
+ onFilesParsed(devices);
+ setFilePreview(null);
+ };
+
+ const handleCancelImport = () => {
+ setFilePreview(null);
+ };
+
+ return (
+ <>
+ {filePreview && (
+
+ )}
+
+
+
+ {variant === 'dropzone' ? (
+ !isImporting && fileInputRef.current?.click()}
+ className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center cursor-pointer transition-colors w-full ${isImporting
+ ? 'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
+ : 'border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-blue-500 dark:hover:border-blue-400'
+ }`}
+ >
+
+
+ {isImporting ? 'Processing file...' : 'Click to upload or drag and drop'}
+
+
+ CSV or Excel files (max 5MB)
+
+
+ ) : (
+ fileInputRef.current?.click()}
+ disabled={isImporting}
+ loading={isImporting}
+ variant="outlined"
+ >
+ Import File
+
+ )}
+ >
+ );
+};
diff --git a/src/vertex-template/components/features/claim/claim-device-modal.tsx b/src/vertex-template/components/features/claim/claim-device-modal.tsx
new file mode 100644
index 0000000000..86e8485d85
--- /dev/null
+++ b/src/vertex-template/components/features/claim/claim-device-modal.tsx
@@ -0,0 +1,984 @@
+// ClaimDeviceModal.tsx
+'use client';
+
+import React, { useState, useCallback, useEffect } from 'react';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { useQueryClient } from '@tanstack/react-query';
+import { useSession } from 'next-auth/react';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+
+import { useAppSelector } from '@/core/redux/hooks';
+import { useRouter } from 'next/navigation';
+import { useClaimDevice, useBulkClaimDevices } from '@/core/hooks/useDevices';
+import { useUserContext } from '@/core/hooks/useUserContext';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+import {
+ useGroupCohorts,
+ useVerifyCohort,
+ useAssignCohortsToGroup,
+ useAssignCohortsToUser,
+} from '@/core/hooks/useCohorts';
+import dynamic from 'next/dynamic';
+
+const StepLoader = () => (
+
+);
+
+const BulkClaimResults = dynamic(() => import('./steps/BulkClaimResults').then(mod => mod.BulkClaimResults), { loading: StepLoader });
+const CohortImportStep = dynamic(() => import('./steps/CohortImportStep'), { loading: StepLoader });
+const ManualInputStep = dynamic(() => import('./steps/ManualInputStep'), { loading: StepLoader });
+const QRScanStep = dynamic(() => import('./steps/QRScanStep'), { loading: StepLoader });
+const SuccessStep = dynamic(() => import('./steps/SuccessStep'), { loading: StepLoader });
+const BulkConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.BulkConfirmationStep), { loading: StepLoader });
+const CohortConfirmStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.CohortConfirmStep), { loading: StepLoader });
+const ConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.ConfirmationStep), { loading: StepLoader });
+const MethodSelectStep = dynamic(() => import('./steps/MethodSelectStep'), { loading: StepLoader });
+const BulkInputStep = dynamic(() => import('./steps/BulkInputStep'), { loading: StepLoader });
+
+import { parseQRCode } from './utils';
+
+// ============================================================
+// MODES
+// ============================================================
+
+export type ClaimFlowMode = 'guided' | 'fast';
+
+// ============================================================
+// TYPES
+// ============================================================
+
+type DialogPrimaryAction = NonNullable<
+ React.ComponentProps['primaryAction']
+>;
+
+type DialogSecondaryAction = NonNullable<
+ React.ComponentProps['secondaryAction']
+>;
+
+export type FlowStep =
+ | 'method-select'
+ | 'manual-input'
+ | 'qr-scan'
+ | 'confirmation'
+ | 'claiming'
+ | 'success'
+ | 'bulk-input'
+ | 'bulk-confirmation'
+ | 'bulk-claiming'
+ | 'bulk-results'
+ | 'cohort-import'
+ | 'cohort-confirm'
+ | 'assigning-cohort';
+
+export interface ClaimedDeviceInfo {
+ deviceId: string;
+ deviceName: string;
+ cohortId: string;
+}
+
+export interface ClaimDeviceModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess?: (deviceInfo: ClaimedDeviceInfo & { isCohortImport?: boolean }) => void;
+ initialStep?: FlowStep;
+ mode?: ClaimFlowMode;
+}
+
+export interface BulkDevice {
+ device_name: string;
+ claim_token: string;
+}
+
+export interface VerifiedCohort {
+ id: string;
+ name: string;
+}
+
+// ============================================================
+// FORM SCHEMA
+// ============================================================
+
+const claimDeviceSchema = z.object({
+ device_id: z
+ .string()
+ .min(1, 'Device ID is required')
+ .min(3, 'Device ID must be at least 3 characters')
+ .regex(
+ /^[a-zA-Z0-9_-]+$/,
+ 'Device ID can only contain letters, numbers, underscores, and hyphens',
+ ),
+ claim_token: z
+ .string()
+ .min(1, 'Claim token is required')
+ .min(4, 'Claim token must be at least 4 characters'),
+});
+
+export type ClaimDeviceFormData = z.infer;
+
+// ============================================================
+// SHARED COMPONENTS
+// ============================================================
+
+export const ErrorAlert = ({ message }: { message: string }) => (
+
+);
+
+const LoadingSpinner = ({
+ title,
+ subtitle,
+}: {
+ title: string;
+ subtitle?: string;
+}) => (
+
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+);
+
+// ============================================================
+// MAIN COMPONENT
+// ============================================================
+
+const ClaimDeviceModal: React.FC = ({
+ isOpen,
+ onClose,
+ onSuccess,
+ initialStep = 'method-select',
+ mode = 'fast',
+}) => {
+ const isGuidedMode = mode === 'guided';
+
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const { data: session } = useSession();
+ const user = useAppSelector(state => state.user.userDetails);
+
+ const { isPersonalContext, isExternalOrg, activeGroup, userScope } =
+ useUserContext();
+
+ const userId = (session?.user as { id?: string })?.id || user?._id;
+
+ // ==========================================================
+ // Cohorts
+ // ==========================================================
+
+ const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, {
+ enabled: !isGuidedMode && !isPersonalContext && !!activeGroup?._id,
+ });
+
+ const defaultCohort = groupCohortIds?.[0] || null;
+
+ // ==========================================================
+ // Mutations
+ // ==========================================================
+
+ const {
+ mutate: claimDevice,
+ isPending,
+ isSuccess,
+ data: claimData,
+ error: claimError,
+ } = useClaimDevice();
+
+ const {
+ mutate: bulkClaimDevices,
+ isPending: isBulkPending,
+ isSuccess: isBulkSuccess,
+ data: bulkClaimData,
+ error: bulkClaimError,
+ } = useBulkClaimDevices();
+
+ const { mutateAsync: verifyCohort } = useVerifyCohort();
+ const { mutate: assignCohortsToGroup } = useAssignCohortsToGroup();
+ const { mutate: assignCohortsToUser } = useAssignCohortsToUser();
+
+ // ==========================================================
+ // State
+ // ==========================================================
+
+ const [step, setStep] = useState(initialStep);
+ const [error, setError] = useState(null);
+ const [bulkDevices, setBulkDevices] = useState([]);
+ const [pendingSingleClaim, setPendingSingleClaim] = useState<{
+ deviceId: string;
+ claimToken: string;
+ } | null>(null);
+ const [cohortIdInput, setCohortIdInput] = useState('');
+
+ // Tracks which step the user came from before confirmation,
+ // so the Cancel/Back button returns to the right place.
+ const [previousStep, setPreviousStep] = useState('method-select');
+
+ const [isImportingCohort, setIsImportingCohort] = useState(false);
+ const [isCohortAssignmentSuccess, setIsCohortAssignmentSuccess] =
+ useState(false);
+ const [verifiedCohort, setVerifiedCohort] = useState(
+ null,
+ );
+
+ // ==========================================================
+ // Form
+ // ==========================================================
+
+ const formMethods = useForm({
+ resolver: zodResolver(claimDeviceSchema),
+ defaultValues: {
+ device_id: '',
+ claim_token: '',
+ },
+ });
+
+ // ==========================================================
+ // Cache Helpers
+ // ==========================================================
+
+ const invalidatePersonalCaches = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts', userId] });
+ queryClient.invalidateQueries({ queryKey: ['myDevices'] });
+ queryClient.invalidateQueries({ queryKey: ['deviceCount', 'personal'] });
+ }, [queryClient, userId]);
+
+ const invalidateGroupCaches = useCallback(() => {
+ queryClient.invalidateQueries({
+ queryKey: ['groupCohorts', activeGroup?._id],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ['deviceCount', activeGroup?._id],
+ });
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ }, [queryClient, activeGroup?._id]);
+
+ // ==========================================================
+ // Reset
+ // ==========================================================
+
+ const resetState = useCallback(() => {
+ formMethods.reset();
+ setStep('method-select');
+ setError(null);
+ setBulkDevices([]);
+ setPendingSingleClaim(null);
+ setCohortIdInput('');
+ setPreviousStep('method-select');
+ setIsImportingCohort(false);
+ setIsCohortAssignmentSuccess(false);
+ setVerifiedCohort(null);
+ }, [formMethods]);
+
+ // Always go through resetState so isCohortAssignmentSuccess etc. are cleared
+ const handleClose = useCallback(() => {
+ resetState();
+ onClose();
+ }, [resetState, onClose]);
+
+ // ==========================================================
+ // Effects
+ // ==========================================================
+
+ useEffect(() => {
+ if (isOpen) {
+ setStep(initialStep);
+ setError(null);
+ formMethods.reset();
+ }
+ }, [isOpen, initialStep, formMethods]);
+
+ // Single claim: success
+ useEffect(() => {
+ if (!isSuccess || !claimData) return;
+
+ if (isPersonalContext) {
+ invalidatePersonalCaches();
+ } else {
+ invalidateGroupCaches();
+ }
+
+ setStep('success');
+
+ if (onSuccess && claimData.device) {
+ onSuccess({
+ deviceId: claimData.device.name,
+ deviceName: claimData.device.long_name || claimData.device.name,
+ cohortId: '',
+ });
+ }
+ }, [
+ isSuccess,
+ claimData,
+ invalidatePersonalCaches,
+ invalidateGroupCaches,
+ isPersonalContext,
+ onSuccess,
+ ]);
+
+ // Single claim: error
+ useEffect(() => {
+ if (claimError) {
+ setError(
+ claimError.message || 'Failed to add airqo device. Please try again.',
+ );
+ setStep('manual-input');
+ }
+ }, [claimError]);
+
+ // Single claim: loading
+ useEffect(() => {
+ if (isPending && step !== 'claiming') {
+ setStep('claiming');
+ }
+ }, [isPending, step]);
+
+ // Bulk claim: success
+ useEffect(() => {
+ if (isBulkSuccess && bulkClaimData) {
+ setStep('bulk-results');
+
+ if (isPersonalContext) {
+ invalidatePersonalCaches();
+ } else {
+ invalidateGroupCaches();
+ }
+ }
+ }, [
+ isBulkSuccess,
+ bulkClaimData,
+ invalidatePersonalCaches,
+ invalidateGroupCaches,
+ isPersonalContext,
+ ]);
+
+ // Bulk claim: error
+ useEffect(() => {
+ if (bulkClaimError) {
+ setError(
+ bulkClaimError.message || 'Failed to add airqo devices. Please try again.',
+ );
+ setStep('bulk-input');
+ }
+ }, [bulkClaimError]);
+
+ // Bulk claim: loading
+ useEffect(() => {
+ if (isBulkPending && step !== 'bulk-claiming') {
+ setStep('bulk-claiming');
+ }
+ }, [isBulkPending, step]);
+
+ // ==========================================================
+ // Single Claim Handlers
+ // ==========================================================
+
+ const handleClaimDevice = (deviceId: string, claimToken: string) => {
+ if (!userId) {
+ setError('User session not available. Please try again.');
+ return;
+ }
+
+ // GUIDED MODE — claim only, no cohort assignment
+ if (isGuidedMode) {
+ setError(null);
+ claimDevice({
+ device_name: deviceId,
+ user_id: userId,
+ claim_token: claimToken,
+ });
+ return;
+ }
+
+ // FAST MODE — claim + optional auto-cohort assignment
+ if (!isPersonalContext && !defaultCohort) {
+ setError('No cohorts found. Please create a cohort first.');
+ return;
+ }
+
+ setError(null);
+ claimDevice({
+ device_name: deviceId,
+ user_id: userId,
+ claim_token: claimToken,
+ ...(defaultCohort && { cohort_id: defaultCohort }),
+ });
+ };
+
+ const onManualSubmit = (data: ClaimDeviceFormData) => {
+ setPendingSingleClaim({
+ deviceId: data.device_id,
+ claimToken: data.claim_token,
+ });
+ setPreviousStep('manual-input');
+ setStep('confirmation');
+ };
+
+ const handleConfirmSingleClaim = () => {
+ if (isPending || !pendingSingleClaim) return;
+ handleClaimDevice(
+ pendingSingleClaim.deviceId,
+ pendingSingleClaim.claimToken,
+ );
+ };
+
+ const handleQRScan = (result: string) => {
+ const parsed = parseQRCode(result);
+
+ if (!parsed) {
+ setError('Invalid QR code format. Please try manual entry.');
+ setStep('manual-input');
+ return;
+ }
+
+ setPendingSingleClaim({
+ deviceId: parsed.deviceId,
+ claimToken: parsed.claimToken,
+ });
+ setPreviousStep('qr-scan');
+ setStep('confirmation');
+ };
+
+ // ==========================================================
+ // Bulk Handlers (FAST MODE ONLY)
+ // ==========================================================
+
+ /** Add a blank device row so the user can type into it. */
+ const handleBulkAddDevice = () => {
+ setBulkDevices(prev => [...prev, { device_name: '', claim_token: '' }]);
+ };
+
+ /** Remove the device row at the given index. */
+ const handleBulkRemoveDevice = (index: number) => {
+ setBulkDevices(prev => prev.filter((_, i) => i !== index));
+ };
+
+ /**
+ * Update a single field on a specific device row.
+ * `field` is either 'device_name' or 'claim_token'.
+ */
+ const handleBulkDeviceChange = (
+ index: number,
+ field: keyof BulkDevice,
+ value: string,
+ ) => {
+ setBulkDevices(prev =>
+ prev.map((device, i) =>
+ i === index ? { ...device, [field]: value } : device,
+ ),
+ );
+ };
+
+ /**
+ * Replace the current device list with devices parsed from an
+ * imported file (CSV, JSON, etc.). The BulkInputStep is responsible
+ * for parsing; it hands us a ready-to-use BulkDevice array.
+ */
+ const handleBulkFileImport = (devices: BulkDevice[]) => {
+ setError(null);
+ setBulkDevices(devices);
+ };
+
+ /** Clear all device rows from the bulk list. */
+ const handleBulkClear = () => {
+ setBulkDevices([]);
+ setError(null);
+ };
+
+ const handleBulkSubmit = () => {
+ if (!userId) {
+ setError('User session not available. Please try again.');
+ return;
+ }
+
+ const valid = bulkDevices.filter(
+ d => d.device_name.trim() && d.claim_token.trim(),
+ );
+
+ if (valid.length === 0) {
+ setError('Please add at least one device with both name and token.');
+ return;
+ }
+
+ setError(null);
+ setStep('bulk-confirmation');
+ };
+
+ const handleConfirmBulkClaim = () => {
+ if (!userId) return;
+
+ const valid = bulkDevices.filter(
+ d => d.device_name.trim() && d.claim_token.trim(),
+ );
+
+ bulkClaimDevices({
+ user_id: userId,
+ devices: valid,
+ ...(defaultCohort && { cohort_id: defaultCohort }),
+ });
+ };
+
+ // ==========================================================
+ // Cohort Assignment (FAST MODE ONLY)
+ // ==========================================================
+
+ const handleVerifyCohort = async () => {
+ const input = cohortIdInput.trim();
+
+ if (!input) {
+ setError('Please enter a valid Cohort ID');
+ return;
+ }
+
+ setError(null);
+ setIsImportingCohort(true);
+
+ try {
+ const result = await verifyCohort(input);
+
+ if (!result.success) {
+ setError(result.message || 'Invalid Cohort ID');
+ return;
+ }
+
+ const cohortName =
+ (result as { data?: { name?: string } }).data?.name ||
+ result?.cohort?.name ||
+ '';
+
+ setVerifiedCohort({ id: input, name: cohortName || input });
+ setStep('cohort-confirm');
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : 'Failed to verify Cohort ID',
+ );
+ } finally {
+ setIsImportingCohort(false);
+ }
+ };
+
+ const handleConfirmCohortImport = () => {
+ if (!verifiedCohort) {
+ setError('Session expired. Please verify the cohort again.');
+ return;
+ }
+
+ setStep('assigning-cohort');
+
+ if (isExternalOrg && activeGroup?._id) {
+ assignCohortsToGroup(
+ { groupId: activeGroup._id, cohortIds: [verifiedCohort.id] },
+ {
+ onSuccess: () => {
+ invalidateGroupCaches();
+ if (onSuccess) {
+ onSuccess({
+ deviceId: '',
+ deviceName: '',
+ cohortId: verifiedCohort.id,
+ isCohortImport: true,
+ });
+ }
+ setTimeout(() => {
+ setIsCohortAssignmentSuccess(true);
+ setStep('success');
+ }, 1500);
+ },
+ onError: err => {
+ setError(getApiErrorMessage(err));
+ setStep('cohort-import');
+ },
+ },
+ );
+ return;
+ }
+
+ if (isPersonalContext && userId) {
+ assignCohortsToUser(
+ { userId, cohortIds: [verifiedCohort.id] },
+ {
+ onSuccess: () => {
+ invalidatePersonalCaches();
+ if (onSuccess) {
+ onSuccess({
+ deviceId: '',
+ deviceName: '',
+ cohortId: verifiedCohort.id,
+ isCohortImport: true,
+ });
+ }
+ setTimeout(() => {
+ setIsCohortAssignmentSuccess(true);
+ setStep('success');
+ }, 1500);
+ },
+ onError: err => {
+ setError(getApiErrorMessage(err));
+ setStep('cohort-import');
+ },
+ },
+ );
+ }
+ };
+
+ // ==========================================================
+ // Dialog Config
+ // ==========================================================
+
+ const getDialogConfig = () => {
+ const base = {
+ title: 'Add AirQo Device',
+ showFooter: false,
+ showCloseButton: true,
+ preventBackdropClose: false,
+ primaryAction: undefined as DialogPrimaryAction | undefined,
+ secondaryAction: undefined as DialogSecondaryAction | undefined,
+ };
+
+ const back = (to: FlowStep): DialogSecondaryAction => ({
+ label: 'Back',
+ onClick: () => setStep(to),
+ variant: 'outline',
+ });
+
+ switch (step) {
+ case 'method-select':
+ return base;
+
+ case 'qr-scan':
+ return {
+ ...base,
+ title: 'Scan QR Code',
+ showFooter: true,
+ secondaryAction: back('method-select'),
+ };
+
+ case 'manual-input':
+ return {
+ ...base,
+ showFooter: true,
+ primaryAction: {
+ label: isPending ? 'Adding...' : 'Add AirQo Device',
+ onClick: formMethods.handleSubmit(onManualSubmit),
+ disabled: isPending,
+ },
+ // Back goes to QR scan in fast mode, or method-select in guided mode.
+ // If the user came from QR scan (previousStep), honour that; otherwise
+ // fall back to method-select.
+ secondaryAction: back(
+ !isGuidedMode && previousStep === 'qr-scan'
+ ? 'qr-scan'
+ : 'method-select',
+ ),
+ };
+
+ case 'confirmation':
+ return {
+ ...base,
+ title: 'Confirm Device',
+ showFooter: true,
+ primaryAction: {
+ label: isPending ? 'Claiming...' : 'Confirm & Continue',
+ onClick: handleConfirmSingleClaim,
+ disabled: isPending,
+ },
+ secondaryAction: {
+ label: 'Cancel',
+ onClick: () => setStep(previousStep),
+ variant: 'outline' as const,
+ },
+ };
+
+ case 'claiming':
+ return {
+ ...base,
+ title: 'Claiming Device...',
+ showCloseButton: false,
+ preventBackdropClose: true,
+ };
+
+ case 'success':
+ return {
+ ...base,
+ title: 'Success!',
+ showFooter: true,
+ primaryAction: {
+ label: isGuidedMode ? 'Continue Setup' : 'See devices',
+ onClick: () => {
+ handleClose();
+ if (!isGuidedMode) {
+ router.push(
+ userScope === 'personal'
+ ? '/devices/my-devices'
+ : '/devices/overview',
+ );
+ }
+ },
+ },
+ };
+
+ // ======================================================
+ // FAST MODE ONLY STEPS
+ // ======================================================
+
+ case 'cohort-import':
+ return {
+ ...base,
+ title: 'Import Cohort',
+ showFooter: true,
+ primaryAction: {
+ label: isImportingCohort ? 'Verifying...' : 'Import',
+ onClick: handleVerifyCohort,
+ disabled: isImportingCohort,
+ },
+ secondaryAction: back('method-select'),
+ };
+
+ case 'cohort-confirm':
+ return {
+ ...base,
+ title: 'Confirm Cohort',
+ showFooter: true,
+ primaryAction: {
+ label: 'Confirm & Import',
+ onClick: handleConfirmCohortImport,
+ },
+ secondaryAction: {
+ label: 'Cancel',
+ onClick: () => setStep('cohort-import'),
+ variant: 'outline' as const,
+ },
+ };
+
+ case 'assigning-cohort':
+ return {
+ ...base,
+ title: 'Assigning Cohort...',
+ showCloseButton: false,
+ preventBackdropClose: true,
+ };
+
+ case 'bulk-input':
+ return {
+ ...base,
+ title: 'Add Multiple Devices',
+ showFooter: true,
+ primaryAction: {
+ label: 'Review & Claim',
+ onClick: handleBulkSubmit,
+ },
+ secondaryAction: back('method-select'),
+ };
+
+ case 'bulk-confirmation':
+ return {
+ ...base,
+ title: 'Confirm Bulk Claim',
+ showFooter: true,
+ primaryAction: {
+ label: isBulkPending ? 'Claiming...' : 'Confirm & Claim',
+ onClick: handleConfirmBulkClaim,
+ disabled: isBulkPending,
+ },
+ secondaryAction: {
+ label: 'Cancel',
+ onClick: () => setStep('bulk-input'),
+ variant: 'outline' as const,
+ },
+ };
+
+ case 'bulk-claiming':
+ return {
+ ...base,
+ title: 'Claiming Devices...',
+ showCloseButton: false,
+ preventBackdropClose: true,
+ };
+
+ case 'bulk-results': {
+ const hasSuccess =
+ (bulkClaimData?.data?.successful_claims?.length ?? 0) > 0;
+
+ return {
+ ...base,
+ title: 'Bulk Claim Results',
+ showFooter: true,
+ primaryAction: {
+ label: hasSuccess ? 'Go to Devices' : 'Close',
+ onClick: () => {
+ handleClose();
+ if (hasSuccess) {
+ router.push(
+ userScope === 'personal'
+ ? '/devices/my-devices'
+ : '/devices/overview',
+ );
+ }
+ },
+ },
+ };
+ }
+
+ default:
+ return base;
+ }
+ };
+
+ const dialogConfig = getDialogConfig();
+
+ // ==========================================================
+ // Render
+ // ==========================================================
+
+ return (
+
+
+ {step === 'method-select' && (
+ // Pass mode so MethodSelectStep can hide bulk/cohort options
+ // in guided mode (only manual + QR should appear).
+
+ )}
+
+ {step === 'qr-scan' && (
+ {
+ setPreviousStep('qr-scan');
+ setStep('manual-input');
+ }}
+ onError={() => {
+ setPreviousStep('qr-scan');
+ setStep('manual-input');
+ setError(
+ 'QR scanner encountered an issue. Please enter details manually.',
+ );
+ }}
+ />
+ )}
+
+ {step === 'manual-input' && (
+
+ )}
+
+ {step === 'confirmation' && pendingSingleClaim && }
+
+ {step === 'claiming' && (
+
+ )}
+
+ {step === 'success' &&
+ (claimData?.device || isCohortAssignmentSuccess) && (
+
+ )}
+
+ <>
+ {step === 'bulk-input' && (
+
+ )}
+
+ {step === 'cohort-import' && (
+ {
+ setCohortIdInput(val);
+ setError(null);
+ }}
+ error={error}
+ isImporting={isImportingCohort}
+ />
+ )}
+
+ {step === 'cohort-confirm' && verifiedCohort && (
+
+ )}
+
+ {step === 'bulk-confirmation' && (
+ d.device_name.trim() && d.claim_token.trim(),
+ ).length
+ }
+ />
+ )}
+
+ {step === 'bulk-claiming' && (
+
+ )}
+
+ {step === 'assigning-cohort' && (
+
+ )}
+
+ {step === 'bulk-results' && bulkClaimData?.data && (
+
+ )}
+ >
+
+
+
+ );
+};
+
+export default ClaimDeviceModal;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx
new file mode 100644
index 0000000000..cb757c8e42
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import React from 'react';
+import { CheckCircle2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
+import { BulkDeviceClaimResponse } from '@/app/types/devices';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+
+interface BulkClaimResultsProps {
+ results: BulkDeviceClaimResponse['data'];
+}
+
+export const BulkClaimResults: React.FC = ({ results }) => {
+ const [showSuccessful, setShowSuccessful] = React.useState(false);
+ const [showFailed, setShowFailed] = React.useState(false);
+
+ const { successful_claims, failed_claims } = results;
+
+ const successful_count = successful_claims.length;
+ const failed_count = failed_claims.length;
+ const total_requested = successful_count + failed_count;
+
+ return (
+
+ {/* Summary Statistics */}
+
+
+
Total Requested
+
{total_requested}
+
+
+
Successful
+
{successful_count}
+
+
+
Failed
+
{failed_count}
+
+
+
+ {/* Successful Claims */}
+ {successful_claims.length > 0 && (
+
+
+
+
+
+
+ Successfully Claimed Devices ({successful_claims.length})
+
+
+ {showSuccessful ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {successful_claims.map((result, index) => (
+
+
+
+
+
+ {result.device?.long_name || result.device_name}
+
+
+ {result.device?.name || result.device_name}
+
+
+
+
Claimed
+
+ ))}
+
+
+
+ )}
+
+ {/* Failed Claims */}
+ {failed_claims.length > 0 && (
+
+
+
+
+
+
+ Failed Claims ({failed_claims.length})
+
+
+ {showFailed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {failed_claims.map((result, index) => (
+
+
+
+
+
+ {result.device_name}
+
+
+ {result.error || 'Unknown error occurred'}
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+};
diff --git a/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx
new file mode 100644
index 0000000000..5bac6b7cb2
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx
@@ -0,0 +1,97 @@
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { BulkDevice, ErrorAlert } from "../claim-device-modal";
+import { FileUploadParser } from "../FileUploadParser";
+import { Plus } from "lucide-react";
+import { DeviceEntryRow } from "../DeviceEntryRow";
+import CohortAssignmentBanner from "./CohortAssignmentBanner";
+
+const BulkInputStep = ({
+ bulkDevices,
+ isPersonalContext,
+ isExternalOrg,
+ defaultCohort,
+ activeGroupTitle,
+ error,
+ onAddDevice,
+ onRemoveDevice,
+ onDeviceChange,
+ onFileImport,
+ onClear,
+}: {
+ bulkDevices: BulkDevice[];
+ isPersonalContext: boolean;
+ isExternalOrg: boolean;
+ defaultCohort: string | null;
+ activeGroupTitle?: string;
+ error: string | null;
+ onAddDevice: () => void;
+ onRemoveDevice: (i: number) => void;
+ onDeviceChange: (i: number, field: 'device_name' | 'claim_token', val: string) => void;
+ onFileImport: (devices: BulkDevice[]) => void;
+ onClear: () => void;
+}) => {
+ if (bulkDevices.length === 0) {
+ return (
+
+
+
+
+
+
+ Enter device details manually
+
+
+ );
+ }
+
+ const validCount = bulkDevices.filter((d) => d.device_name.trim() && d.claim_token.trim()).length;
+
+ return (
+ <>
+ {!isPersonalContext && defaultCohort && (
+
+ )}
+
+
Review your devices before claiming.
+
Clear All
+
+
+ {bulkDevices.map((device, index) => (
+ onDeviceChange(index, 'device_name', val)}
+ onClaimTokenChange={(val) => onDeviceChange(index, 'claim_token', val)}
+ onRemove={() => onRemoveDevice(index)}
+ showRemove
+ />
+ ))}
+
+
+
Add Row
+
+ onFileImport([...bulkDevices, ...devices])} />
+
+
+ {error && }
+
+ {validCount} device(s) ready to claim
+
+ >
+ );
+};
+
+export default BulkInputStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx
new file mode 100644
index 0000000000..0c024fbd09
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx
@@ -0,0 +1,22 @@
+const CohortAssignmentBanner = ({
+ isExternalOrg,
+ isPersonalContext,
+ activeGroupTitle,
+}: {
+ isExternalOrg: boolean;
+ isPersonalContext: boolean;
+ activeGroupTitle?: string;
+}) => (
+
+
+ Device will be added to:
+ {isExternalOrg && activeGroupTitle && ` ${activeGroupTitle}`}
+ {isPersonalContext && ` as your personal devices`}
+
+
+ You can change ownership or share devices later.
+
+
+);
+
+export default CohortAssignmentBanner;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx
new file mode 100644
index 0000000000..37dec07cc5
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx
@@ -0,0 +1,33 @@
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { Loader2 } from "lucide-react";
+
+const CohortImportStep = ({
+ cohortIdInput,
+ onChange,
+ error,
+ isImporting,
+}: {
+ cohortIdInput: string;
+ onChange: (val: string) => void;
+ error: string | null;
+ isImporting: boolean;
+}) => (
+
+
Enter the Cohort ID to automatically load its devices.
+
onChange(e.target.value)}
+ error={error || undefined}
+ />
+ {isImporting && (
+
+
+ Verifying Cohort ID...
+
+ )}
+
+);
+
+export default CohortImportStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx
new file mode 100644
index 0000000000..3f89e966b8
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx
@@ -0,0 +1,79 @@
+import { AlertCircle } from "lucide-react";
+import { VerifiedCohort } from "../claim-device-modal";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+
+
+const DeploymentWarning = ({ isBulk = false }: { isBulk?: boolean }) => (
+
+
+ Warning: {isBulk ? 'Any devices currently' : 'If this device is currently'}{' '}
+
+
+ deployed
+
+
+ Deployment triggers data transmission for a device
+
+
+ {isBulk ? ' will be automatically' : ', it will be automatically'}{' '}
+
+
+ recalled
+
+
+ Recalling removes a device from a Site (e.g., for repair) without deleting it from your inventory.
+
+
+ {isBulk ? ' and added to your inventory.' : '.'}
+
+
+);
+
+export const CohortConfirmStep = ({
+ verifiedCohort,
+ isExternalOrg,
+}: {
+ verifiedCohort: VerifiedCohort;
+ isExternalOrg: boolean;
+}) => (
+
+
+
+ Cohort Name: {verifiedCohort.name}
+
+
+ This will add the devices from this cohort to your {isExternalOrg ? 'organization' : 'personal'} assets.
+
+
+
Continue to import this cohort?
+
+);
+
+export const BulkConfirmationStep = ({ count }: { count: number }) => (
+
+
+
+
Confirm Bulk Claim
+
+ You are about to claim {count} devices.
+
+
+
+
+);
+
+export const ConfirmationStep = () => (
+
+
+
+
Confirm Device Claim
+
Are you sure you want to claim this device?
+
+
+
+);
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx
new file mode 100644
index 0000000000..4b898f18b3
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx
@@ -0,0 +1,53 @@
+import { useForm } from 'react-hook-form';
+import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
+import { Form, FormField } from '@/components/ui/form';
+import { ClaimDeviceFormData, ErrorAlert } from '../claim-device-modal';
+import CohortAssignmentBanner from './CohortAssignmentBanner';
+
+const ManualInputStep = ({
+ formMethods,
+ isPersonalContext,
+ isExternalOrg,
+ defaultCohort,
+ activeGroupTitle,
+ error,
+}: {
+ formMethods: ReturnType>;
+ isPersonalContext: boolean;
+ isExternalOrg: boolean;
+ defaultCohort: string | null;
+ activeGroupTitle?: string;
+ error: string | null;
+}) => (
+
+);
+
+export default ManualInputStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx
new file mode 100644
index 0000000000..c6bf594840
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx
@@ -0,0 +1,89 @@
+import {
+ // Smartphone,
+ // FileSpreadsheet,
+ Database } from 'lucide-react';
+import { ClaimFlowMode, FlowStep } from '../claim-device-modal';
+
+interface MethodSelectStepProps {
+ onSelect: (step: FlowStep) => void;
+ mode?: ClaimFlowMode;
+}
+
+const ALL_METHODS = [
+ {
+ step: 'cohort-import' as FlowStep,
+ icon: (
+
+ ),
+ iconBg:
+ 'bg-violet-100 dark:bg-violet-900/40 group-hover:bg-violet-200 dark:group-hover:bg-violet-800',
+ border:
+ 'hover:border-violet-500 dark:hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20',
+ title: 'Import from Cohort',
+ desc: 'Enter a Cohort ID to prefill devices.',
+ modes: ['guided', 'fast'] as ClaimFlowMode[],
+ },
+ /*
+ {
+ step: 'qr-scan' as FlowStep,
+ icon: ,
+ iconBg:
+ 'bg-blue-100 dark:bg-blue-900/40 group-hover:bg-blue-200 dark:group-hover:bg-blue-800',
+ border:
+ 'hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20',
+ title: 'Add Single Device',
+ desc: 'Scan a QR code or manually enter a Device ID.',
+ modes: ['guided', 'fast'] as ClaimFlowMode[],
+ },
+ {
+ step: 'bulk-input' as FlowStep,
+ icon: (
+
+ ),
+ iconBg:
+ 'bg-green-100 dark:bg-green-900/40 group-hover:bg-green-200 dark:group-hover:bg-green-800',
+ border:
+ 'hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20',
+ title: 'Add Multiple Devices',
+ desc: 'Upload a CSV file or enter a list of IDs for bulk setup.',
+ modes: ['guided', 'fast'] as ClaimFlowMode[],
+ },
+ */
+];
+
+const MethodSelectStep = ({ onSelect, mode = 'fast' }: MethodSelectStepProps) => {
+ const visibleMethods = ALL_METHODS.filter(m => m.modes.includes(mode));
+
+ return (
+
+
+ Choose how you would like to add your device(s).
+
+
+ {visibleMethods.map(({ step, icon, iconBg, border, title, desc }) => (
+
onSelect(step)}
+ className={`flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg ${border} transition-colors text-left w-full group`}
+ >
+
+ {icon}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default MethodSelectStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/QRScanStep.tsx b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx
new file mode 100644
index 0000000000..700f0eae0a
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx
@@ -0,0 +1,98 @@
+import { Component, ReactNode } from "react";
+import { QRScanner } from "../../devices/qr-scanner";
+import CohortAssignmentBanner from "./CohortAssignmentBanner";
+import logger from "@/lib/logger";
+
+// ============================================================
+// ERROR BOUNDARY
+// ============================================================
+
+interface QRScannerErrorBoundaryProps {
+ children: ReactNode;
+ isOpen?: boolean;
+ fallback?: ReactNode;
+ onError?: () => void;
+}
+
+interface QRScannerErrorBoundaryState {
+ hasError: boolean;
+}
+
+class QRScannerErrorBoundary extends Component {
+ constructor(props: QRScannerErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ componentDidUpdate(prevProps: QRScannerErrorBoundaryProps) {
+ if (!prevProps.isOpen && this.props.isOpen && this.state.hasError) {
+ this.setState({ hasError: false });
+ }
+ }
+
+ static getDerivedStateFromError(): QRScannerErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error) {
+ if (process.env.NODE_ENV === 'development') {
+ logger.warn('QR Scanner error caught by boundary:', error);
+ }
+ this.props.onError?.();
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) return this.props.fallback || null;
+ return this.props.children;
+ }
+}
+
+const QRScanStep = ({
+ isOpen,
+ isPersonalContext,
+ isExternalOrg,
+ defaultCohort,
+ activeGroupTitle,
+ onScan,
+ onManualEntry,
+ onError,
+}: {
+ isOpen: boolean;
+ isPersonalContext: boolean;
+ isExternalOrg: boolean;
+ defaultCohort: string | null;
+ activeGroupTitle?: string;
+ onScan: (result: string) => void;
+ onManualEntry: () => void;
+ onError: () => void;
+}) => (
+
+ {!isPersonalContext && defaultCohort && (
+
+ )}
+
+ Scanner unavailable. Enter details manually
+
+ }
+ >
+ {isOpen && }
+
+
+ Having trouble? Enter details manually
+
+
+);
+
+export default QRScanStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/steps/SuccessStep.tsx b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx
new file mode 100644
index 0000000000..01d027b901
--- /dev/null
+++ b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx
@@ -0,0 +1,40 @@
+import { CheckCircle2 } from "lucide-react";
+
+const SuccessStep = ({
+ isCohortAssignmentSuccess,
+ claimData,
+}: {
+ isCohortAssignmentSuccess: boolean;
+ claimData?: { device?: { name: string; long_name?: string } };
+}) => (
+
+
+
+
+
+
+ {isCohortAssignmentSuccess ? 'Cohort Assigned Successfully!' : 'Device Claimed Successfully!'}
+
+
+ {claimData?.device && (
+
+
+ Device Name:
+ {claimData.device.long_name || claimData.device.name}
+
+
+ Device ID:
+ {claimData.device.name}
+
+
+ Status:
+
+ Claimed
+
+
+
+ )}
+
+);
+
+export default SuccessStep;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/claim/utils.ts b/src/vertex-template/components/features/claim/utils.ts
new file mode 100644
index 0000000000..2c6ace582d
--- /dev/null
+++ b/src/vertex-template/components/features/claim/utils.ts
@@ -0,0 +1,31 @@
+export function parseQRCode(
+ qrData: string
+): { deviceId: string; claimToken: string } | null {
+ try {
+ const url = new URL(qrData);
+ const deviceId = url.searchParams.get('id');
+ const claimToken = url.searchParams.get('token');
+ if (deviceId && claimToken) return { deviceId, claimToken };
+ } catch {
+ /* not a URL */
+ }
+
+ try {
+ const parsed = JSON.parse(qrData);
+ if (
+ typeof parsed?.device_id === "string" &&
+ parsed.device_id.trim() &&
+ typeof parsed?.token === "string" &&
+ parsed.token.trim()
+ ) {
+ return {
+ deviceId: parsed.device_id,
+ claimToken: parsed.token,
+ };
+ }
+ } catch {
+ /* not JSON */
+ }
+
+ return null;
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx
new file mode 100644
index 0000000000..e8b825d68e
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { useCohorts, useAssignDevicesToCohort, useGroupCohorts } from "@/core/hooks/useCohorts";
+import { useDevices } from "@/core/hooks/useDevices";
+import { ComboBox } from "@/components/ui/combobox";
+import { AqPlus } from "@airqo/icons-react";
+import { MultiSelectCombobox, Option } from "@/components/ui/multi-select";
+import { CreateCohortDialog, PreselectedDevice } from "./create-cohort";
+import { Cohort } from "@/app/types/cohorts";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { Device } from "@/app/types/devices";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+interface AssignCohortDevicesDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedDevices?: Device[];
+ onSuccess?: () => void;
+ cohortId?: string;
+ title?: string;
+}
+
+const formSchema = z.object({
+ cohortId: z.string().min(1, {
+ message: "Please select a cohort.",
+ }),
+ devices: z.array(z.string()).min(1, {
+ message: "Please select at least one device.",
+ }),
+});
+
+export function AssignCohortDevicesDialog({
+ open,
+ onOpenChange,
+ selectedDevices,
+ onSuccess,
+ cohortId,
+ title = "Add devices to cohort",
+}: AssignCohortDevicesDialogProps) {
+ const { isExternalOrg, activeGroup } = useUserContext();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const [cohortSearch, setCohortSearch] = useState("");
+ const [debouncedCohortSearch, setDebouncedCohortSearch] = useState("");
+ const [deviceSearch, setDeviceSearch] = useState("");
+ const [debouncedDeviceSearch, setDebouncedDeviceSearch] = useState("");
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedCohortSearch(cohortSearch);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [cohortSearch]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedDeviceSearch(deviceSearch);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [deviceSearch]);
+
+ const { cohorts: allCohorts, isFetching: isFetchingAllCohorts } = useCohorts({
+ enabled: open && !isExternalOrg,
+ search: debouncedCohortSearch,
+ limit: 100
+ });
+
+ const { data: groupCohortIds, isFetching: isFetchingCohortIds } = useGroupCohorts(
+ activeGroup?._id,
+ { enabled: open && isExternalOrg && !!activeGroup?._id }
+ );
+
+ const { cohorts: searchedCohorts, isFetching: isFetchingGroupCohorts } = useCohorts({
+ enabled: open && isExternalOrg && !!activeGroup?._id,
+ search: debouncedCohortSearch,
+ limit: 100
+ });
+
+ const filteredGroupCohorts = useMemo(() => {
+ if (!isExternalOrg || !groupCohortIds || groupCohortIds.length === 0) {
+ return searchedCohorts;
+ }
+ const cohortIdSet = new Set(groupCohortIds);
+ return searchedCohorts.filter(cohort => cohortIdSet.has(cohort._id));
+ }, [isExternalOrg, searchedCohorts, groupCohortIds]);
+
+ const cohorts = isExternalOrg ? filteredGroupCohorts : allCohorts;
+ const isFetchingCohorts = isExternalOrg ? (isFetchingGroupCohorts || isFetchingCohortIds) : isFetchingAllCohorts;
+
+ const { devices: allDevices, isFetching: isFetchingDevices } = useDevices({
+ enabled: open,
+ search: debouncedDeviceSearch,
+ });
+ const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort({
+ onSuccess: (variables) => {
+ showBannerWithDelay({
+ severity: 'success',
+ message: `${variables.deviceIds.length} device(s) assigned to cohort successfully`,
+ scoped: false,
+ });
+ },
+ onError: (error) => {
+ showBanner({
+ severity: 'error',
+ message: `Failed to assign devices: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ },
+ });
+
+ const [createCohortModalOpen, setCreateCohortModalOpen] = useState(false);
+ const [preselectedForCreate, setPreselectedForCreate] = useState([]);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ cohortId: cohortId || "",
+ devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [],
+ },
+ });
+
+ const deviceOptions: Option[] = useMemo(() => {
+ const combined = [...(selectedDevices ?? []), ...(allDevices ?? [])];
+ const unique = new Map();
+
+ combined.forEach((device) => {
+ if (!device?._id) return;
+ unique.set(device._id, {
+ value: device._id,
+ label: device.long_name || device.name || `Device ${device._id}`,
+ });
+ });
+
+ return Array.from(unique.values());
+ }, [allDevices, selectedDevices]);
+
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ cohortId: cohortId || "",
+ devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [],
+ });
+ setCohortSearch("");
+ setDebouncedCohortSearch("");
+ setDeviceSearch("");
+ setDebouncedDeviceSearch("");
+ }
+ }, [open, form, cohortId, selectedDevices]);
+
+ const handleCreateCohortSuccess = () => {
+ setCreateCohortModalOpen(false);
+ onOpenChange(false);
+ };
+
+ const handleCreateCohortClose = (open: boolean) => {
+ if (!open) {
+ setCreateCohortModalOpen(false);
+ }
+ };
+
+ const handleCreateCohortAction = () => {
+ const selectedIds = form.getValues("devices") || [];
+ const preselected = deviceOptions
+ .filter((opt) => selectedIds.includes(opt.value))
+ .map((opt) => ({ value: opt.value, label: opt.label }));
+
+ setPreselectedForCreate(preselected); // store in state
+ onOpenChange(false);
+ setCreateCohortModalOpen(true);
+ };
+
+
+ function onSubmit(values: z.infer) {
+ assignDevices(
+ {
+ cohortId: values.cohortId,
+ deviceIds: values.devices,
+ },
+ {
+ onSuccess: () => {
+ onOpenChange(false);
+ form.reset();
+ onSuccess?.();
+ },
+ }
+ );
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen);
+ if (!newOpen) {
+ form.reset();
+ setCreateCohortModalOpen(false);
+ }
+ };
+
+ return (
+ <>
+ handleOpenChange(false)}
+ title={title}
+ subtitle={`${form.watch("devices")?.length || 0} device(s) selected`}
+ size="lg"
+ maxHeight="max-h-[70vh]"
+ primaryAction={{
+ label: "Add",
+ onClick: form.handleSubmit(onSubmit),
+ disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isAssigning,
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => onOpenChange(false),
+ variant: "outline",
+ disabled: isAssigning,
+ }}
+ >
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx
new file mode 100644
index 0000000000..247a158e5f
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { useAssignCohortsToGroup, useCohorts } from "@/core/hooks/useCohorts";
+import { useGroups } from "@/core/hooks/useGroups";
+import { ComboBox } from "@/components/ui/combobox";
+import { MultiSelectCombobox, Option } from "@/components/ui/multi-select";
+import { Cohort } from "@/app/types/cohorts";
+import { Group } from "@/app/types/groups";
+
+interface AssignCohortsToGroupDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initialSelectedCohortIds: string[];
+ onSuccess?: () => void;
+}
+
+export function AssignCohortsToGroupDialog({
+ open,
+ onOpenChange,
+ initialSelectedCohortIds,
+ onSuccess,
+}: AssignCohortsToGroupDialogProps) {
+ const [selectedGroupId, setSelectedGroupId] = useState("");
+ const [selectedCohortIds, setSelectedCohortIds] = useState(initialSelectedCohortIds);
+ const [errors, setErrors] = useState<{ group?: string; cohorts?: string }>({});
+
+ const { cohorts: allCohorts } = useCohorts();
+ const { groups: allGroups, isLoading: isLoadingGroups } = useGroups();
+ const { mutate: assignToGroup, isPending } = useAssignCohortsToGroup();
+
+ useEffect(() => {
+ if (open) {
+ setSelectedCohortIds(initialSelectedCohortIds);
+ }
+ }, [open, initialSelectedCohortIds]);
+
+ const cohortOptions: Option[] = useMemo(() => {
+ return (allCohorts || []).map((cohort: Cohort) => ({
+ value: cohort._id,
+ label: cohort.name,
+ }));
+ }, [allCohorts]);
+
+ const groupOptions = useMemo(() => {
+ return (allGroups || []).map((group: Group) => ({
+ value: group._id,
+ label: group.grp_title,
+ }));
+ }, [allGroups]);
+
+ const handleClose = () => {
+ onOpenChange(false);
+ // Reset state after dialog closes
+ setTimeout(() => {
+ setSelectedGroupId("");
+ setSelectedCohortIds([]);
+ setErrors({});
+ }, 150); // Delay to allow for closing animation
+ };
+
+ const handleSubmit = () => {
+ const newErrors: { group?: string; cohorts?: string } = {};
+ if (!selectedGroupId) {
+ newErrors.group = "Please select an organization.";
+ }
+ if (selectedCohortIds.length === 0) {
+ newErrors.cohorts = "Please select at least one cohort.";
+ }
+
+ setErrors(newErrors);
+
+ if (Object.keys(newErrors).length > 0) {
+ return;
+ }
+
+ assignToGroup(
+ {
+ groupId: selectedGroupId,
+ cohortIds: selectedCohortIds,
+ },
+ {
+ onSuccess: () => {
+ onSuccess?.();
+ handleClose();
+ },
+ }
+ );
+ };
+
+ return (
+
+
+
+
Organization *
+
{
+ setSelectedGroupId(value);
+ if (errors.group) setErrors(e => ({...e, group: undefined}));
+ }}
+ placeholder="Select an organization"
+ searchPlaceholder="Search organization..."
+ emptyMessage={isLoadingGroups ? "Loading organization..." : "No organization found."}
+ disabled={isPending || isLoadingGroups}
+ allowCustomInput={false}
+ />
+ {errors.group && {errors.group}
}
+
+
+
Cohorts *
+
{
+ setSelectedCohortIds(values);
+ if (errors.cohorts) setErrors(e => ({...e, cohorts: undefined}));
+ }}
+ placeholder="Select cohorts..."
+ allowCreate={false}
+ />
+ {errors.cohorts && {errors.cohorts}
}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx
new file mode 100644
index 0000000000..76a4d0ca87
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx
@@ -0,0 +1,319 @@
+import { Card } from "@/components/ui/card";
+import { Loader2 } from "lucide-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqCopy01, AqEdit01 } from "@airqo/icons-react";
+import { Switch } from "@/components/ui/switch";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { useEffect, useState } from "react";
+import { useUpdateCohortDetails, useOriginalCohort } from "@/core/hooks/useCohorts";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { Badge } from "@/components/ui/badge";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices";
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import { InfoBanner } from "@/components/ui/banner";
+
+interface CohortDetailsCardProps {
+ name: string;
+ id: string;
+ visibility: boolean;
+ onShowDetailsModal: () => void;
+ loading: boolean;
+ cohort_tags?: string[];
+}
+
+const CohortDetailsCard: React.FC = ({
+ name,
+ id,
+ visibility,
+ onShowDetailsModal,
+ loading,
+ cohort_tags,
+}) => {
+ const [isVisibilityDialogOpen, setIsVisibilityDialogOpen] = useState(false);
+ const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false);
+ const [targetVisibility, setTargetVisibility] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const pathname = usePathname();
+ const isAdminPath = pathname.startsWith("/admin");
+ const isDuplicate = (cohort_tags ?? []).some(
+ (tag) => tag.toLowerCase() === "duplicate"
+ );
+
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails();
+ const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate });
+ const originalCohort = isDuplicate && originalData?.success ? originalData.original_cohort : null;
+
+ useEffect(() => {
+ setSelectedTags(cohort_tags ?? []);
+ }, [cohort_tags]);
+
+ const handleToggleVisibility = (checked: boolean) => {
+ setTargetVisibility(checked);
+ setIsVisibilityDialogOpen(true);
+ };
+
+ const handleConfirmVisibilityUpdate = async () => {
+ try {
+ await updateCohort({ cohortId: id, data: { visibility: targetVisibility } });
+ setIsVisibilityDialogOpen(false);
+ showBannerWithDelay({
+ severity: 'success',
+ message: `Cohort is now ${targetVisibility ? 'public' : 'private'}`,
+ scoped: false,
+ });
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ message: `Failed to update visibility: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ }
+ };
+
+ const handleConfirmTagsUpdate = async () => {
+ try {
+ await updateCohort({ cohortId: id, data: { cohort_tags: selectedTags } });
+ setIsTagsDialogOpen(false);
+ showBannerWithDelay({
+ severity: 'success',
+ message: 'Cohort tags updated successfully',
+ scoped: false,
+ });
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ message: `Failed to update tags: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
Cohort Details
+
+
+
+
+
+
+
+ Tags
+
+
setIsTagsDialogOpen(true)}
+ className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full h-fit w-fit"
+ Icon={AqEdit01}
+ aria-label="Edit tags"
+ />
+
+
+ {cohort_tags && cohort_tags.length > 0 ? (
+ cohort_tags.map((tag) => (
+
+ {tag}
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+
+ Visibility
+
+
+
+ Private
+
+
+
+ Public
+
+
+
+
+
+
+ Cohort ID
+
+
+
+ {id || "-"}
+
+
{
+ if (!id) return;
+ try {
+ await navigator.clipboard.writeText(id);
+ showBanner({ severity: 'success', message: 'Cohort ID copied to clipboard', scoped: false });
+ } catch {
+ showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false });
+ }
+ }}
+ className="p-1"
+ Icon={AqCopy01}
+ />
+
+
+
+
+ {originalCohort && (
+
+ This cohort is a duplicate. The original cohort is{" "}
+
+ {originalCohort.name}
+
+ >
+ }
+ />
+ )}
+
+
+
+ !isPending && setIsVisibilityDialogOpen(false)}
+ title={
+ targetVisibility ? "Make cohort public?" : "Make cohort private?"
+ }
+ size="md"
+ customFooter={
+
+ setIsVisibilityDialogOpen(false)}
+ disabled={isPending}
+ >
+ Cancel
+
+
+ {isPending
+ ? "Updating..."
+ : targetVisibility
+ ? "Confirm & Publish"
+ : "Confirm & Make Private"}
+
+
+ }
+ >
+
+
+ {targetVisibility
+ ? "You are about to make this cohort visible on the public AirQo Map. This means anyone can see readings from devices in this cohort."
+ : "You are about to make this cohort private. Data from devices in this cohort will only be visible to your organization and will not appear on the public map."}
+
+
+
+
+ !isPending && setIsTagsDialogOpen(false)}
+ title="Edit Cohort Tags"
+ size="md"
+ customFooter={
+
+ setIsTagsDialogOpen(false)}
+ disabled={isPending}
+ >
+ Cancel
+
+
+ {isPending ? "Updating..." : "Save Changes"}
+
+
+ }
+ >
+
+
+
+
+ >
+ );
+};
+
+export default CohortDetailsCard;
diff --git a/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx
new file mode 100644
index 0000000000..d158e0e304
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Copy } from "lucide-react";
+import React from "react";
+import { useClipboard } from "@/core/hooks/useClipboard";
+
+interface CohortMeasurementsApiCardProps {
+ cohortId: string;
+}
+
+const CohortMeasurementsApiCard: React.FC = ({ cohortId }) => {
+ const { handleCopy } = useClipboard({ successMessage: 'API URL copied to clipboard', errorMessage: 'Failed to copy to clipboard' });
+
+ return (
+
+ Cohort Measurements API
+ {/* Recent Measurements */}
+
+
Recent Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`}
+
+
handleCopy(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`)}
+ >
+
+
+
+
+ {/* Historical Measurements */}
+
+
Historical Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`}
+
+
handleCopy(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`)}
+ >
+
+
+
+
+
+ );
+};
+
+export default CohortMeasurementsApiCard;
diff --git a/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx
new file mode 100644
index 0000000000..4275b03187
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx
@@ -0,0 +1,229 @@
+import { useState, useMemo } from "react";
+import { Card } from "@/components/ui/card";
+import { Loader2 } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { Group } from "@/app/types/groups";
+import { useGroupsByCohort } from "@/core/hooks/useGroups";
+import { UnassignCohortFromGroupDialog } from "./unassign-cohort-from-group";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableTable, { TableColumn, TableItem } from "@/components/shared/table/ReusableTable";
+import ReusableToast from "@/components/shared/toast/ReusableToast";
+import { AqCopy01 } from "@airqo/icons-react";
+import { formatTitle } from "../org-picker/organization-picker";
+
+interface CohortOrganizationsCardProps {
+ cohortId: string;
+ cohortName: string;
+ canUnassign?: boolean;
+}
+
+export function CohortOrganizationsCard({
+ cohortId,
+ cohortName,
+ canUnassign = false,
+}: CohortOrganizationsCardProps) {
+ const { groups: organizations, isLoading, refetch } = useGroupsByCohort(cohortId);
+
+ const [selectedOrg, setSelectedOrg] = useState(null);
+ const [showUnassignDialog, setShowUnassignDialog] = useState(false);
+ const [showAllDialog, setShowAllDialog] = useState(false);
+
+ const handleUnassignClick = (org: Group) => {
+ setSelectedOrg(org);
+ setShowUnassignDialog(true);
+ };
+
+ const handleUnassignSuccess = () => {
+ setShowUnassignDialog(false);
+ setSelectedOrg(null);
+ refetch(); // Refetch the organizations assigned to the cohort
+ };
+
+ const handleCopyId = (id: string) => {
+ navigator.clipboard.writeText(id);
+ ReusableToast({ message: "Group ID copied to clipboard!", type: "SUCCESS" });
+ };
+
+ const tableData = useMemo(() => {
+ return organizations.map((org) => ({ ...org, id: org._id }));
+ }, [organizations]);
+
+ const tableColumns: TableColumn[] = [
+ {
+ key: "grp_title",
+ label: "Name",
+ render: (value, item) => {formatTitle(item.grp_title)} ,
+ sortable: true,
+ },
+ {
+ key: "_id",
+ label: "Org ID",
+ render: (value, item) => (
+
+ {item._id}
+ {
+ e.stopPropagation();
+ handleCopyId(item._id);
+ }}
+ Icon={AqCopy01}
+ aria-label="Copy Group ID"
+ />
+
+ ),
+ },
+ {
+ key: "grp_country",
+ label: "Country",
+ render: (value, item) => item.grp_country || "-",
+ sortable: true,
+ },
+ {
+ key: "actions" as keyof (Group & { id: string }),
+ label: "Action",
+ render: (value, item) => (
+
+
+
+
+ handleUnassignClick(item)}
+ disabled={!canUnassign}
+ className="data-[state=checked]:bg-red-600"
+ aria-label="Unassign from cohort"
+ />
+
+
+
+ Remove cohort assignment
+
+
+
+ ),
+ },
+ ];
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const displayOrganizations = organizations.slice(0, 3);
+ const hasMore = organizations.length > 3;
+
+ return (
+ <>
+
+
+
Assigned Organizations
+
+ {organizations.length === 0 ? (
+
+ No organizations assigned to this cohort.
+
+ ) : (
+
+ {displayOrganizations.map((org) => (
+
+
+
+
+ {org.grp_title}
+
+
+
+
+ Organization ID
+
+
+
+ {org._id}
+
+ handleCopyId(org._id)}
+ className="p-1 h-auto text-muted-foreground hover:text-primary"
+ Icon={AqCopy01}
+ aria-label="Copy Organization ID"
+ />
+
+
+
+
+
+ handleUnassignClick(org)}
+ disabled={!canUnassign}
+ className="text-xs px-2 py-1 text-red-600 border-red-200 hover:bg-red-500 dark:hover:bg-red-950 dark:border-red-900"
+ >
+ Unassign
+
+
+
+
+ ))}
+
+ )}
+
+
+ {hasMore && (
+
+ setShowAllDialog(true)}
+ >
+ View more organizations ({organizations.length - 3})
+
+
+ )}
+
+
+ {/* Dialog for "View More" table */}
+ setShowAllDialog(false)}
+ title={`All Assigned Organizations (${organizations.length})`}
+ size="3xl"
+ >
+
+ []}
+ searchable={true}
+ searchableColumns={["grp_title", "grp_country", "_id"]}
+ filterable={false}
+ showPagination={true}
+ pageSize={5}
+ pageSizeOptions={[5, 10, 20]}
+ exportable={false}
+ tableId={`cohort-orgs-${cohortId}`}
+ />
+
+
+
+ {/* Unassign Confirmation Dialog */}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx
new file mode 100644
index 0000000000..8f2dd89c45
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Plus } from 'lucide-react';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { AqCollocation } from '@airqo/icons-react';
+import dynamic from 'next/dynamic';
+
+const ClaimDeviceModal = dynamic(
+ () => import('../claim/claim-device-modal'),
+ { ssr: false }
+);
+
+const CohortsEmptyState = () => {
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
+
+ return (
+
+
+
+
+ No Cohorts Found
+
+
+
+ Get started by claiming widespread AirQo devices to automatically create your first device cohort.
+
+
+
+ setIsClaimModalOpen(true)} Icon={Plus}>
+ Add AirQo Device
+
+
+
+
setIsClaimModalOpen(false)}
+ />
+
+ );
+};
+
+export default CohortsEmptyState;
diff --git a/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx
new file mode 100644
index 0000000000..5fd4a420e0
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { useCreateCohortFromCohorts } from "@/core/hooks/useCohorts";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { Label } from "@/components/ui/label";
+import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices";
+import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+
+interface CreateCohortFromSelectionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedCohortIds: string[];
+ onSuccess?: () => void;
+ andNavigate?: boolean;
+}
+
+export function CreateCohortFromSelectionDialog({
+ open,
+ onOpenChange,
+ selectedCohortIds,
+ onSuccess,
+ andNavigate = true,
+}: CreateCohortFromSelectionDialogProps) {
+ const [city, setCity] = useState("");
+ const [projectName, setProjectName] = useState("");
+ const [funder, setFunder] = useState("");
+ const [name, setName] = useState("");
+ const [network, setNetwork] = useState("");
+ const [description, setDescription] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [error, setError] = useState("");
+ const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({
+ city: false,
+ projectName: false,
+ funder: false,
+ });
+ const tooltipTimers = useRef | null>>({});
+ const router = useRouter();
+ const { mutate: createFromCohorts, isPending } = useCreateCohortFromCohorts();
+ const { networks, isLoading: isLoadingNetworks } = useNetworks();
+
+ useEffect(() => {
+ if (!open) {
+ setCity("");
+ setProjectName("");
+ setFunder("");
+ setName("");
+ setNetwork("");
+ setDescription("");
+ setNetwork("");
+ setDescription("");
+ setSelectedTags([]);
+ setError("");
+ }
+ }, [open]);
+
+ const handleClose = () => {
+ onOpenChange(false);
+ };
+
+ const handleSubmit = () => {
+ const isOrganizational = selectedTags.includes("organizational");
+ if (isOrganizational) {
+ if (city.trim().length === 0) {
+ setError("City is required.");
+ return;
+ }
+ if (projectName.trim().length === 0) {
+ setError("Project name is required.");
+ return;
+ }
+ } else if (name.trim().length === 0) {
+ setError("Cohort name is required.");
+ return;
+ }
+ if (!network) {
+ setError("Please select a Sensor Manufacturer.");
+ return;
+ }
+ if (selectedTags.length === 0) {
+ setError("Please select at least one tag.");
+ return;
+ }
+ setError("");
+
+ const derivedName = isOrganizational
+ ? buildCohortName(city, projectName, funder)
+ : name.trim();
+ if (derivedName.length < 2) {
+ setError("Cohort name must be at least 2 characters.");
+ return;
+ }
+
+ createFromCohorts(
+ {
+ name: derivedName,
+ description: description.trim() || undefined,
+ cohort_ids: selectedCohortIds,
+ network,
+ cohort_tags: selectedTags,
+ },
+ {
+ onSuccess: (response) => {
+ onSuccess?.();
+ if (andNavigate && response?.data?._id) {
+ router.push(`/admin/cohorts/${response.data._id}`);
+ } else {
+ handleClose();
+ }
+ },
+ }
+ );
+ };
+
+ const handleSanitizedInputChange = (
+ fieldKey: "city" | "projectName" | "funder",
+ value: string,
+ setter: (nextValue: string) => void
+ ) => {
+ const sanitized = sanitizeCohortInput(value);
+ if (/[^a-zA-Z0-9]/.test(value)) {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true }));
+ if (tooltipTimers.current[fieldKey]) {
+ clearTimeout(tooltipTimers.current[fieldKey] as ReturnType);
+ }
+ tooltipTimers.current[fieldKey] = setTimeout(() => {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false }));
+ }, 1500);
+ }
+ setter(sanitized);
+ };
+
+ return (
+
+
+
+ Tags
+
+
+
+ {selectedTags.includes("organizational") ? (
+ <>
+
+
+
+
+
+ handleSanitizedInputChange("city", e.target.value, setCity)} placeholder="e.g. Nairobi" required />
+
+
+
+ Special character ignored
+
+
+
+
+
+
+
+ handleSanitizedInputChange("projectName", e.target.value, setProjectName)} placeholder="e.g. WRI" required />
+
+
+
+ Special character ignored
+
+
+
+
+
+
+
+ handleSanitizedInputChange("funder", e.target.value, setFunder)} placeholder="e.g. EPIC" />
+
+
+
+ Special character ignored
+
+
+
+
+ {error && (
+
{error}
+ )}
+ >
+ ) : (
+
setName(e.target.value)}
+ placeholder="Enter cohort name"
+ required
+ />
+ )}
+ {!selectedTags.includes("organizational") && error && (
+ {error}
+ )}
+ setNetwork(e.target.value)}
+ required
+ placeholder={isLoadingNetworks ? "Loading sensor manufacturer..." : "Select a sensor manufacturer"}
+ disabled={isLoadingNetworks}
+ >
+ {networks.map((network) => (
+
+ {network.net_name}
+
+ ))}
+
+
+ setDescription(e.target.value)} placeholder="Describe this combined cohort" rows={3} />
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/cohorts/create-cohort.tsx b/src/vertex-template/components/features/cohorts/create-cohort.tsx
new file mode 100644
index 0000000000..e159693d97
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/create-cohort.tsx
@@ -0,0 +1,587 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { AlertCircle, CheckCircle2 } from "lucide-react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { useDevices } from "@/core/hooks/useDevices";
+import { useCreateCohortWithDevices } from "@/core/hooks/useCohorts";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { DeviceNameParser } from "./device-name-parser";
+import { useBanner } from "@/context/banner-context";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useAppSelector } from "@/core/redux/hooks";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Label } from "@/components/ui/label";
+import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices";
+import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+
+export type PreselectedDevice = { value: string; label: string };
+
+const EMPTY_PRESELECTED_DEVICES: PreselectedDevice[] = [];
+
+interface CreateCohortDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: (cohortData?: { cohort: { _id: string; name: string } }) => void;
+ onError?: (error: unknown) => void;
+ andNavigate?: boolean;
+ preselectedDevices?: PreselectedDevice[];
+ hideDeviceSelection?: boolean;
+ preselectedNetwork?: string;
+}
+
+const formSchema = z.object({
+ name: z.string().optional(),
+ city: z.string().optional(),
+ projectName: z.string().optional(),
+ funder: z.string().optional(),
+ network: z.string().min(1, {
+ message: "Please select a Sensor Manufacturer.",
+ }),
+ devices: z.array(z.string()).optional(),
+ cohort_tags: z.array(z.string()).optional(),
+}).superRefine((values, ctx) => {
+ const isOrganizational = values.cohort_tags?.includes("organizational");
+ if (isOrganizational) {
+ if (!values.city?.trim()) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "City is required.", path: ["city"] });
+ }
+ if (!values.projectName?.trim()) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Project name is required.", path: ["projectName"] });
+ }
+ } else {
+ if (!values.name?.trim()) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Cohort name is required.", path: ["name"] });
+ }
+ }
+});
+
+export function CreateCohortDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ onError,
+ preselectedDevices = EMPTY_PRESELECTED_DEVICES,
+ hideDeviceSelection = false,
+ preselectedNetwork,
+ andNavigate = true,
+}: CreateCohortDialogProps) {
+ const { showBanner } = useBanner();
+ const pathname = usePathname();
+ const isAdminPage = pathname?.includes('/admin/');
+ const { isExternalOrg, activeGroup } = useUserContext();
+ const userDetails = useAppSelector((state) => state.user.userDetails);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: "",
+ city: "",
+ projectName: "",
+ funder: "",
+ network: preselectedNetwork || "",
+ devices: preselectedDevices.map((d) => d.value),
+ cohort_tags: [],
+ },
+ });
+
+ type CohortStep = "form" | "confirmation" | "success";
+ const [step, setStep] = useState("form");
+ const [createdCohort, setCreatedCohort] = useState<{ _id: string; name: string } | null>(null);
+
+ const selectedNetwork = form.watch("network");
+ const [deviceSearch, setDeviceSearch] = useState("");
+ const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({
+ city: false,
+ projectName: false,
+ funder: false,
+ });
+ const tooltipTimers = useRef | null>>({});
+
+ const { devices, isLoading, error } = useDevices({
+ network: selectedNetwork,
+ search: deviceSearch
+ });
+
+ const { networks, isLoading: isLoadingNetworks } = useNetworks();
+
+ const deviceOptions = useMemo(() => {
+ return (devices || []).map((d) => ({
+ value: d._id || "",
+ label: d.long_name || d.name || `Device ${d._id}`,
+ }));
+ }, [devices]);
+
+ const router = useRouter();
+
+ const handleSanitizedFieldChange = (
+ fieldKey: "city" | "projectName" | "funder",
+ value: string,
+ onChange: (nextValue: string) => void
+ ) => {
+ const sanitized = sanitizeCohortInput(value);
+ if (/[^a-zA-Z0-9]/.test(value)) {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true }));
+ if (tooltipTimers.current[fieldKey]) {
+ clearTimeout(tooltipTimers.current[fieldKey] as ReturnType);
+ }
+ tooltipTimers.current[fieldKey] = setTimeout(() => {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false }));
+ }, 1500);
+ }
+ onChange(sanitized);
+ };
+
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ name: "",
+ city: "",
+ projectName: "",
+ funder: "",
+ network: preselectedNetwork || "",
+ devices: preselectedDevices.map((d) => d.value),
+ cohort_tags: [],
+ });
+ setDeviceSearch("");
+ setStep("form");
+ setCreatedCohort(null);
+
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ // When embedded in another modal (andNavigate=false + hideDeviceSelection=true),
+ // skip the success step and close immediately so query invalidations don't
+ // trigger a re-render cascade in the parent modal.
+ const isEmbeddedMode = !andNavigate && hideDeviceSelection;
+
+ const { mutate: createCohort, isPending } = useCreateCohortWithDevices({
+ invalidateOnSuccess: !isEmbeddedMode,
+ onSuccess: (response) => {
+ if (isEmbeddedMode) {
+ onSuccess?.(response);
+ onOpenChange(false);
+ } else if (response?.cohort) {
+ setCreatedCohort(response.cohort);
+ setStep("success");
+ onSuccess?.(response);
+ } else {
+ onSuccess?.(response);
+ onOpenChange(false);
+ }
+ },
+ onError: (error) => {
+ showBanner({
+ severity: 'error',
+ message: `Failed to create cohort: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ onError?.(error);
+ },
+ });
+
+ const handleCancel = () => {
+ if (step === 'form') {
+ form.reset();
+ setDeviceSearch("");
+ onOpenChange(false);
+ } else if (step === 'success') {
+ onOpenChange(false);
+ } else {
+ setStep('form');
+ }
+ };
+
+ const handleDeviceImport = (deviceNames: string[]) => {
+ // Validate that network is selected first (generic block handles this now, but extra safety)
+ if (!selectedNetwork) return;
+
+ // Convert device names to device IDs by finding matches in deviceOptions
+ const matchedIds = deviceNames
+ .map(name => {
+ const nameLower = name.toLowerCase();
+ // Try exact match first
+ let match = deviceOptions.find(d => d.label.toLowerCase() === nameLower);
+
+ // If no exact match, try contains match
+ if (!match) {
+ match = deviceOptions.find(d =>
+ d.label.toLowerCase().includes(nameLower) && nameLower.length >= 3
+ );
+ }
+
+ return match?.value;
+ })
+ .filter(Boolean) as string[];
+
+ if (matchedIds.length === 0) {
+ showBanner({
+ severity: 'warning',
+ message: 'No matching devices found. Please ensure the devices exist in the selected network.',
+ scoped: true,
+ });
+ return;
+ }
+
+ // Merge with existing selections
+ const currentDevices = form.getValues('devices') || [];
+ const uniqueDevices = Array.from(new Set([...currentDevices, ...matchedIds]));
+ form.setValue('devices', uniqueDevices);
+
+ const importedCount = matchedIds.length;
+ const notFoundCount = deviceNames.length - importedCount;
+
+ if (notFoundCount > 0) {
+ showBanner({
+ severity: 'warning',
+ message: `Imported ${importedCount} device${importedCount !== 1 ? 's' : ''}. ${notFoundCount} not found.`,
+ scoped: true,
+ });
+ }
+ };
+
+ // Intermediate submit handler for form step
+ const onFormReview = () => {
+ setStep("confirmation");
+ };
+
+ // Final submit handler
+ const handleConfirmCreate = () => {
+ const values = form.getValues();
+ const isOrganizational = values.cohort_tags?.includes("organizational");
+ const derivedName = isOrganizational
+ ? buildCohortName(values.city || "", values.projectName || "", values.funder)
+ : (values.name || "").trim();
+
+ const payload: Parameters[0] = {
+ name: derivedName,
+ network: values.network,
+ deviceIds: [],
+ };
+
+ if (values.devices && values.devices.length > 0) {
+ payload.deviceIds = values.devices;
+ }
+
+ if (values.cohort_tags && values.cohort_tags.length > 0) {
+ payload.cohort_tags = values.cohort_tags;
+ }
+
+ if (isExternalOrg && activeGroup?._id) {
+ payload.groupId = activeGroup._id;
+ } else if (!isExternalOrg && !isAdminPage && userDetails?._id) {
+ payload.userId = userDetails._id;
+ }
+
+ createCohort(payload);
+ };
+
+ const getDialogConfig = () => {
+ switch (step) {
+ case 'confirmation':
+ return {
+ title: 'Confirm Cohort Creation',
+ primaryLabel: isPending ? 'Creating...' : 'Confirm & Create',
+ primaryAction: handleConfirmCreate,
+ secondaryLabel: 'Back',
+ secondaryAction: () => setStep('form'),
+ showFooter: true
+ };
+ case 'success':
+ return {
+ title: 'Success!',
+ primaryLabel: andNavigate ? 'Go to Cohort Details' : 'Close',
+ primaryAction: () => {
+ if (andNavigate && createdCohort?._id) {
+ router.push(`/admin/cohorts/${createdCohort._id}`);
+ } else {
+ onOpenChange(false);
+ }
+ },
+ secondaryLabel: 'Close',
+ secondaryAction: () => onOpenChange(false),
+ showFooter: true
+ };
+ default: // form
+ return {
+ title: 'Create Cohort',
+ primaryLabel: 'Review & Create',
+ primaryAction: form.handleSubmit(onFormReview),
+ secondaryLabel: 'Cancel',
+ secondaryAction: handleCancel,
+ showFooter: true
+ };
+ }
+ };
+
+ const config = getDialogConfig();
+ const formValues = form.getValues();
+ const isOrganizational = form.watch("cohort_tags")?.includes("organizational");
+ const derivedName = isOrganizational
+ ? buildCohortName(formValues.city || "", formValues.projectName || "", formValues.funder)
+ : (formValues.name || "").trim();
+
+ return (
+
+ {step === 'form' && (
+
+
+ )}
+
+ {step === 'confirmation' && (
+
+
+
+
+ Review Cohort Details
+
+
+ You are about to create a cohort named {derivedName} in the {formValues.network} network.
+
+ {!hideDeviceSelection && (
+
+
+ Devices to be added:
+
+
+ {formValues.devices?.length || 0}
+
+
+ )}
+
+
+ )}
+
+ {step === 'success' && createdCohort && (
+
+
+
+
+
+
+ Cohort Created Successfully!
+
+
+ Cohort {createdCohort.name} has been created{hideDeviceSelection ? '.' : ` with ${formValues.devices?.length || 0} devices.`}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/vertex-template/components/features/cohorts/device-name-parser.tsx b/src/vertex-template/components/features/cohorts/device-name-parser.tsx
new file mode 100644
index 0000000000..5a094870a8
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/device-name-parser.tsx
@@ -0,0 +1,320 @@
+'use client';
+
+import React, { useRef, useState } from 'react';
+import { FileSpreadsheet } from 'lucide-react';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { useBanner } from '@/context/banner-context';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export interface ParsedFileData {
+ headers: string[];
+ data: unknown[][];
+ fileName: string;
+}
+
+interface DeviceNameColumnMapperProps {
+ filePreview: ParsedFileData;
+ onConfirm: (deviceNameColumn: number) => void;
+ onCancel: () => void;
+}
+
+const DeviceNameColumnMapper: React.FC = ({
+ filePreview,
+ onConfirm,
+ onCancel,
+}) => {
+ const [deviceNameColumn, setDeviceNameColumn] = useState(() => {
+ // Auto-detect device name column
+ const index = filePreview.headers.findIndex(h =>
+ /device.*name|name|device.*id|device|long.*name|device_name/i.test(h.toLowerCase())
+ );
+ return index !== -1 ? index : 0;
+ });
+
+ return (
+
+
+
+
+ Map Device Name Column
+
+
+ File: {filePreview.fileName}
+
+
+
+
+
+ Please select which column contains the device names:
+
+
+ {/* Column Selector */}
+
+
+ Device Name Column
+
+ setDeviceNameColumn(Number(e.target.value))}
+ className="w-full max-w-md px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary"
+ >
+ {filePreview.headers.map((header, index) => (
+
+ {header}
+
+ ))}
+
+
+
+ {/* Preview Table */}
+
+
+
+
+ {filePreview.headers.map((header, index) => (
+
+ {header}
+ {index === deviceNameColumn && (
+ (Device Name)
+ )}
+
+ ))}
+
+
+
+ {filePreview.data.slice(1, 6).map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+ {String(cell || '')}
+
+ ))}
+
+ ))}
+
+
+
+
+ Showing first 5 rows as preview
+
+
+
+
+
+ Cancel
+
+ onConfirm(deviceNameColumn)}>
+ Import Devices
+
+
+
+
+ );
+};
+
+interface DeviceNameCohortParserProps {
+ onDevicesParsed: (deviceNames: string[]) => void;
+ shouldBlock?: boolean;
+ tooltipMessage?: string;
+ onBlock?: () => void;
+}
+
+export const DeviceNameParser: React.FC = ({
+ onDevicesParsed,
+ shouldBlock = false,
+ tooltipMessage,
+ onBlock
+}) => {
+ const [isImporting, setIsImporting] = useState(false);
+ const [filePreview, setFilePreview] = useState(null);
+ const fileInputRef = useRef(null);
+ const { showBanner } = useBanner();
+
+ const handleLinkClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (shouldBlock) {
+ onBlock?.();
+ return;
+ }
+
+ if (!isImporting) {
+ fileInputRef.current?.click();
+ }
+ };
+
+ const handleFileImport = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
+ if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) {
+ showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true });
+ e.target.value = '';
+ return;
+ }
+
+ if (file.size > 5 * 1024 * 1024) {
+ showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true });
+ e.target.value = '';
+ return;
+ }
+
+ setIsImporting(true);
+
+ try {
+ let parsedData: unknown[][] = [];
+ let headers: string[] = [];
+
+ if (fileExtension === 'csv') {
+ const Papa = (await import('papaparse')).default;
+ Papa.parse(file, {
+ complete: (results) => {
+ parsedData = results.data as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ } else {
+ showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true });
+ }
+ setIsImporting(false);
+ },
+ error: (error) => {
+ showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true });
+ setIsImporting(false);
+ },
+ });
+ } else {
+ const XLSX = await import('xlsx');
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ const bstr = evt.target?.result;
+ const workbook = XLSX.read(bstr, { type: 'binary' });
+ const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
+ parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ } else {
+ showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true });
+ }
+ setIsImporting(false);
+ };
+ reader.onerror = () => {
+ showBanner({ severity: 'error', message: 'Error reading Excel file.', scoped: true });
+ setIsImporting(false);
+ };
+ reader.readAsBinaryString(file);
+ }
+ } catch {
+ showBanner({ severity: 'error', message: 'Error importing file. Please try again.', scoped: true });
+ setIsImporting(false);
+ }
+
+ e.target.value = '';
+ };
+
+ const handleConfirmImport = (deviceNameColumn: number) => {
+ if (!filePreview) return;
+
+ const deviceNames = filePreview.data
+ .slice(1)
+ .map((row) => {
+ const deviceName = row[deviceNameColumn];
+ return typeof deviceName === 'string'
+ ? deviceName.trim()
+ : String(deviceName || '').trim();
+ })
+ .filter((name) => name.length > 0);
+
+ if (deviceNames.length === 0) {
+ showBanner({ severity: 'error', message: 'No valid device names found in the selected column.', scoped: true });
+ return;
+ }
+
+ onDevicesParsed(deviceNames);
+ setFilePreview(null);
+ };
+
+ const handleCancelImport = () => {
+ setFilePreview(null);
+ };
+
+ return (
+
+ {filePreview && (
+
+ )}
+
+
+
+ {shouldBlock && tooltipMessage ? (
+
+
+
+
+ Import from CSV
+
+
+
+ {tooltipMessage}
+
+
+ ) : (
+ e.preventDefault()}
+ onClick={handleLinkClick}
+ disabled={isImporting}
+ className="text-sm text-blue-600 dark:text-blue-400 hover:underline font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
+ >
+
+ {isImporting ? 'Processing...' : 'Import from CSV'}
+
+ )}
+
+ );
+};
diff --git a/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx
new file mode 100644
index 0000000000..5c8bbbe95a
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { Label } from "@/components/ui/label";
+
+import { useUpdateCohortDetails, useUpdateCohortName } from "@/core/hooks/useCohorts";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { buildCohortName, sanitizeCohortInput, splitCohortName } from "@/core/utils/cohortName";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices";
+
+interface CohortDetailsModalProps {
+ open: boolean;
+ cohortDetails: {
+ name: string;
+ id: string;
+ visibility: boolean;
+ cohort_tags: string[];
+ };
+ onClose: () => void;
+}
+
+const isStructuredCohortName = (name: string) => {
+ const parts = name.split("_").filter(Boolean);
+ return parts.length >= 2 && parts.length <= 3 && parts.every((part) => /^[a-z0-9]+$/.test(part));
+};
+
+const CohortDetailsModal: React.FC = ({
+ open,
+ cohortDetails,
+ onClose,
+}) => {
+ const [form, setForm] = useState({
+ name: "",
+ city: "",
+ projectName: "",
+ funder: "",
+ updateReason: "",
+ tags: [] as string[],
+ });
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const updateCohortName = useUpdateCohortName();
+ const updateCohortDetails = useUpdateCohortDetails();
+ const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({
+ city: false,
+ projectName: false,
+ funder: false,
+ });
+ const tooltipTimers = useRef | null>>({});
+
+ useEffect(() => {
+ if (!open) return;
+ const { city, projectName, funder } = splitCohortName(cohortDetails.name || "");
+ setForm({
+ name: cohortDetails.name || "",
+ city,
+ projectName,
+ funder,
+ updateReason: "",
+ tags: cohortDetails.cohort_tags || [],
+ });
+ }, [open, cohortDetails]);
+
+ const handleCancel = () => {
+ const { city, projectName, funder } = splitCohortName(cohortDetails.name || "");
+ setForm({
+ name: cohortDetails.name || "",
+ city,
+ projectName,
+ funder,
+ updateReason: "",
+ tags: cohortDetails.cohort_tags || [],
+ });
+ onClose();
+ };
+
+ const handleSave = async () => {
+ const tagsChanged =
+ JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort());
+ const isOrganizational = form.tags.includes("organizational");
+ const trimmedName = form.name.trim();
+ const trimmedCity = form.city.trim();
+ const trimmedProject = form.projectName.trim();
+ const trimmedReason = form.updateReason.trim();
+ const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName);
+ const derivedName = isOrganizational
+ ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder))
+ : trimmedName;
+ const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name;
+ if (!isOrganizational && trimmedName.length === 0) return;
+ if (isOrganizational && !legacyOrganizationalName && (trimmedCity.length === 0 || trimmedProject.length === 0)) return;
+ if (requiresUpdateReason && trimmedReason.length === 0) return;
+ const nameChanged = derivedName.length > 0 && derivedName !== cohortDetails.name;
+
+ if (!nameChanged && !tagsChanged) return onClose();
+
+ try {
+ if (isOrganizational && nameChanged) {
+ await updateCohortName.mutateAsync({
+ cohortId: cohortDetails.id,
+ name: derivedName,
+ updateReason: trimmedReason,
+ });
+ if (tagsChanged) {
+ await updateCohortDetails.mutateAsync({
+ cohortId: cohortDetails.id,
+ data: { cohort_tags: form.tags },
+ });
+ }
+ } else if (!isOrganizational && nameChanged) {
+ await updateCohortDetails.mutateAsync({
+ cohortId: cohortDetails.id,
+ data: { name: derivedName, cohort_tags: form.tags },
+ });
+ } else if (tagsChanged) {
+ await updateCohortDetails.mutateAsync({
+ cohortId: cohortDetails.id,
+ data: { cohort_tags: form.tags },
+ });
+ }
+ onClose();
+ showBannerWithDelay({
+ severity: 'success',
+ message: 'Cohort details updated successfully',
+ scoped: false,
+ });
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ message: `Failed to update cohort: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ }
+ };
+
+ const handleSanitizedInputChange = (
+ fieldKey: "city" | "projectName" | "funder",
+ value: string
+ ) => {
+ const sanitized = sanitizeCohortInput(value);
+ if (/[^a-zA-Z0-9]/.test(value)) {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true }));
+ if (tooltipTimers.current[fieldKey]) {
+ clearTimeout(tooltipTimers.current[fieldKey] as ReturnType);
+ }
+ tooltipTimers.current[fieldKey] = setTimeout(() => {
+ setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false }));
+ }, 1500);
+ }
+ setForm((prev) => ({ ...prev, [fieldKey]: sanitized }));
+ };
+
+ const isOrganizational = form.tags.includes("organizational");
+ const trimmedName = form.name.trim();
+ const trimmedCity = form.city.trim();
+ const trimmedProject = form.projectName.trim();
+ const trimmedReason = form.updateReason.trim();
+ const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName);
+ const derivedName = isOrganizational
+ ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder))
+ : trimmedName;
+ const tagsChanged =
+ JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort());
+ const isSaving = updateCohortName.isPending || updateCohortDetails.isPending;
+ const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name;
+ const canSave =
+ (isOrganizational
+ ? (legacyOrganizationalName
+ ? trimmedName.length > 0
+ : trimmedCity.length > 0 && trimmedProject.length > 0)
+ : trimmedName.length > 0) &&
+ (!requiresUpdateReason || trimmedReason.length > 0) &&
+ derivedName.length > 0 &&
+ (derivedName !== cohortDetails.name || tagsChanged);
+
+ return (
+
+ Cancel
+
+ {isSaving ? "Saving..." : "Save"}
+
+
+ }
+ >
+
+
+ Tags
+ setForm((s) => ({ ...s, tags: values }))}
+ value={form.tags}
+ allowCreate={false}
+ />
+
+
+ {isOrganizational ? (
+ <>
+ {legacyOrganizationalName ? (
+
setForm((s) => ({ ...s, name: e.target.value }))}
+ placeholder="Enter cohort name"
+ disabled={isSaving}
+ required
+ description="Legacy cohort name detected. Update will preserve this format."
+ />
+ ) : (
+ <>
+
+
+
+
+
+ handleSanitizedInputChange("city", e.target.value)}
+ placeholder="e.g. Nairobi"
+ disabled={isSaving}
+ required
+ />
+
+
+
+ Special character ignored
+
+
+
+
+
+
+
+ handleSanitizedInputChange("projectName", e.target.value)}
+ placeholder="e.g. WRI"
+ disabled={isSaving}
+ required
+ />
+
+
+
+ Special character ignored
+
+
+
+
+
+
+
+ handleSanitizedInputChange("funder", e.target.value)}
+ placeholder="e.g. EPIC"
+ disabled={isSaving}
+ />
+
+
+
+ Special character ignored
+
+
+
+
+
+ Cohort name will be: {derivedName || "-"}
+
+ >
+ )}
+ setForm((s) => ({ ...s, updateReason: e.target.value }))}
+ placeholder="Why is this name changing?"
+ disabled={isSaving}
+ required
+ />
+ >
+ ) : (
+ setForm((s) => ({ ...s, name: e.target.value }))}
+ placeholder="Enter cohort name"
+ disabled={isSaving}
+ required
+ />
+ )}
+
+
+ );
+};
+
+export default CohortDetailsModal;
diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx
new file mode 100644
index 0000000000..84d0cb3918
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { useEffect, useMemo } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { useCohortDetails, useCohorts, useUnassignDevicesFromCohort } from "@/core/hooks/useCohorts";
+import { ComboBox } from "@/components/ui/combobox";
+import { MultiSelectCombobox, Option } from "@/components/ui/multi-select";
+import { Cohort } from "@/app/types/cohorts";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { Device } from "@/app/types/devices";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+interface UnassignCohortDevicesDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedDevices?: Device[];
+ onSuccess?: () => void;
+ cohortId?: string;
+ cohortDevices?: Device[];
+}
+
+const formSchema = z.object({
+ cohortId: z.string().min(1, {
+ message: "Please select a cohort.",
+ }),
+ devices: z.array(z.string()).min(1, {
+ message: "Please select at least one device.",
+ }),
+});
+
+export function UnassignCohortDevicesDialog({
+ open,
+ onOpenChange,
+ selectedDevices,
+ onSuccess,
+ cohortId,
+ cohortDevices = [],
+}: UnassignCohortDevicesDialogProps) {
+ const { cohorts } = useCohorts();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { mutate: unassignDevices, isPending: isUnassigning } = useUnassignDevicesFromCohort({
+ onSuccess: (variables) => {
+ showBannerWithDelay({
+ severity: 'success',
+ message: `${variables.device_ids.length} device(s) removed from cohort successfully`,
+ scoped: false,
+ });
+ },
+ onError: (error) => {
+ showBanner({
+ severity: 'error',
+ message: `Failed to remove devices: ${getApiErrorMessage(error)}`,
+ scoped: true,
+ });
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ cohortId: cohortId || "",
+ devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [],
+ },
+ });
+
+ const deviceOptions: Option[] = useMemo(() => {
+ const devicesToShow = selectedDevices && selectedDevices.length > 0 ? selectedDevices : cohortDevices;
+ return devicesToShow
+ .map((device) => ({
+ value: device._id || "",
+ label: device.long_name || device.name || `Device ${device._id}`,
+ }))
+ .filter((option) => option.value);
+ }, [cohortDevices, selectedDevices]);
+
+ const watchedCohortId = form.watch("cohortId");
+ const { data: fetchedCohort } = useCohortDetails(
+ watchedCohortId,
+ { enabled: !selectedDevices?.length && !cohortDevices.length && !!watchedCohortId }
+ );
+
+ const dynamicDeviceOptions: Option[] = useMemo(() => {
+ if (deviceOptions.length) return deviceOptions;
+ const list = fetchedCohort?.devices || [];
+ return list
+ .filter((d: Device) => !!d._id)
+ .map((d: Device) => ({
+ value: d._id!,
+ label: d.long_name || d.name || `Device ${d._id}`,
+ }));
+ }, [deviceOptions, fetchedCohort]);
+
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ cohortId: cohortId || "",
+ devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [],
+ });
+ }
+ }, [open, selectedDevices, form, cohortId]);
+
+ function onSubmit(values: z.infer) {
+ unassignDevices(
+ {
+ cohortId: values.cohortId,
+ device_ids: values.devices,
+ },
+ {
+ onSuccess: () => {
+ onOpenChange(false);
+ form.reset();
+ onSuccess?.();
+ },
+ }
+ );
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen);
+ if (!newOpen) {
+ form.reset();
+ }
+ };
+
+ return (
+ handleOpenChange(false)}
+ title="Remove devices from cohort"
+ subtitle={`${form.watch("devices")?.length || 0} device(s) selected`}
+ size="lg"
+ maxHeight="max-h-[70vh]"
+ primaryAction={{
+ label: "Remove",
+ onClick: form.handleSubmit(onSubmit),
+ disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isUnassigning,
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => handleOpenChange(false),
+ variant: "outline",
+ disabled: isUnassigning,
+ }}
+ >
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx
new file mode 100644
index 0000000000..8d7a3e1cd9
--- /dev/null
+++ b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { Group } from "@/app/types/groups";
+import { useUnassignCohortsFromGroup } from "@/core/hooks/useCohorts";
+
+interface UnassignCohortFromGroupDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ organization: Group | null;
+ cohortId: string;
+ cohortName: string;
+ onSuccess?: () => void;
+}
+
+export function UnassignCohortFromGroupDialog({
+ open,
+ onOpenChange,
+ organization,
+ cohortId,
+ cohortName,
+ onSuccess,
+}: UnassignCohortFromGroupDialogProps) {
+ const { mutate: unassignFromGroup, isPending } = useUnassignCohortsFromGroup({
+ onSuccess: () => {
+ onSuccess?.();
+ },
+ });
+
+ const handleConfirm = () => {
+ if (!organization) return;
+
+ unassignFromGroup(
+ {
+ groupId: organization._id,
+ cohortIds: [cohortId],
+ },
+ {
+ onSuccess: () => {
+ onOpenChange(false);
+ },
+ }
+ );
+ };
+
+ return (
+ !isPending && onOpenChange(false)}
+ title="Unassign Organization"
+ size="md"
+ customFooter={
+
+ onOpenChange(false)}
+ disabled={isPending}
+ >
+ Cancel
+
+
+ {isPending ? "Unassigning..." : "Confirm Unassign"}
+
+
+ }
+ >
+
+
+ Are you sure you want to unassign {organization?.grp_title} from cohort {cohortName} ?
+
+
+ This action will remove the organization's access to this cohort. This cannot be undone.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/dashboard/stat-card.tsx b/src/vertex-template/components/features/dashboard/stat-card.tsx
new file mode 100644
index 0000000000..702ba5cb68
--- /dev/null
+++ b/src/vertex-template/components/features/dashboard/stat-card.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { Info } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useCallback } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+interface StatCardProps {
+ title: string;
+ value: number | string;
+ description?: string;
+ icon: React.ReactNode;
+ isLoading?: boolean;
+ variant?: "default" | "success" | "warning" | "destructive" | "info" | "primary";
+ size?: "sm" | "md" | "lg";
+ onClick?: () => void;
+ isActive?: boolean;
+}
+
+export const StatCard = ({
+ title,
+ value,
+ description,
+ icon,
+ isLoading,
+ variant = "default",
+ size = "md",
+ onClick,
+ isActive = false,
+}: StatCardProps) => {
+ const getContainerStyles = useCallback(() => {
+ const baseStyles = "rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 relative overflow-hidden p-0 transition-all duration-200";
+
+ if (isLoading) {
+ return baseStyles;
+ }
+
+ const interactiveStyles = onClick ? "cursor-pointer hover:shadow-md hover:border-primary/50" : "";
+ const activeStyles = isActive ? "ring-2 ring-primary border-primary shadow-md" : "";
+ return cn(baseStyles, interactiveStyles, activeStyles);
+ }, [onClick, isActive, isLoading]);
+
+ const getIconColor = useCallback(() => {
+ switch (variant) {
+ case "success":
+ return "text-green-500 bg-green-50 dark:bg-green-900/10";
+ case "warning":
+ return "text-yellow-500 bg-yellow-50 dark:bg-yellow-900/10";
+ case "destructive":
+ return "text-red-500 bg-red-50 dark:bg-red-900/10";
+ case "info":
+ return "text-blue-500 bg-blue-50 dark:bg-blue-900/10";
+ case "primary":
+ return "text-primary bg-primary/10";
+ default:
+ return "text-gray-500 bg-gray-50 dark:bg-gray-900/10";
+ }
+ }, [variant]);
+
+ // Size configurations
+ const sizeConfig = {
+ sm: {
+ padding: "p-3",
+ gap: "gap-2",
+ titleSize: "text-sm",
+ valueSize: "text-xl",
+ iconContainer: "p-1.5 rounded-lg",
+ iconSize: "w-4 h-4",
+ },
+ md: {
+ padding: "p-4",
+ gap: "gap-4",
+ titleSize: "text-md",
+ valueSize: "text-3xl",
+ iconContainer: "p-2 rounded-xl",
+ iconSize: "w-6 h-6",
+ },
+ lg: {
+ padding: "p-6",
+ gap: "gap-6",
+ titleSize: "text-lg",
+ valueSize: "text-4xl",
+ iconContainer: "p-3 rounded-2xl",
+ iconSize: "w-8 h-8",
+ },
+ };
+
+ const config = sizeConfig[size];
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ {description && }
+
+
+
+
+
+ );
+ }
+
+ // Clone icon to apply size classes if it's a valid element
+ // But since we pass icon as a node, we might need to wrap it or expect correct size
+ // For flexibility, we'll suggest passing icon with correct size or override here if possible
+ // Using a wrapper div to constrain size might be safer if the icon is SVG
+
+ return (
+
+
{
+ if (onClick && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ role={onClick ? "button" : undefined}
+ tabIndex={onClick ? 0 : undefined}
+ aria-label={onClick ? `View ${title.toLowerCase()}` : undefined}
+ >
+
+
+
+
+ {title}
+
+ {description && (
+
+
+
+
+
+
+ {description}
+
+
+
+ )}
+
+
+
+
+ {value}
+
+
+ {icon}
+
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/dashboard/stats-cards.tsx b/src/vertex-template/components/features/dashboard/stats-cards.tsx
new file mode 100644
index 0000000000..9b10f58ffd
--- /dev/null
+++ b/src/vertex-template/components/features/dashboard/stats-cards.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useDeviceCount } from "@/core/hooks/useDevices";
+import { usePersonalUserCohorts } from "@/core/hooks/useCohorts";
+import { useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+import {
+ AqMonitor,
+ AqCollocation,
+ AqWifiOff,
+ AqData,
+} from "@airqo/icons-react";
+import { useMemo } from "react";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useAppSelector } from "@/core/redux/hooks";
+import { getStatusExplanation } from "@/core/utils/status";
+import { StatCard } from "./stat-card";
+
+export const DashboardStatsCards = () => {
+ const { data: session } = useSession();
+ const { userScope } = useUserContext();
+ const user = useAppSelector((state) => state.user.userDetails);
+
+ const isPersonalScope = userScope === 'personal';
+ const userId = (session?.user as { id?: string })?.id || user?._id;
+
+ // Fetch personal user cohorts
+ const { data: personalCohortIds = [], isLoading: isLoadingPersonalCohorts } = usePersonalUserCohorts(
+ userId,
+ { enabled: !!userId && isPersonalScope }
+ );
+
+ const shouldEnable = isPersonalScope ? personalCohortIds.length > 0 : true;
+
+ // Use useDeviceCount for both scopes
+ // For personal scope, pass the user's cohort IDs
+ // If personal scope and no cohorts, the hook is disabled
+ const deviceCountQuery = useDeviceCount({
+ enabled: shouldEnable,
+ cohortIds: isPersonalScope ? personalCohortIds : undefined,
+ });
+
+ const isLoading = (isPersonalScope && isLoadingPersonalCohorts) || (shouldEnable && deviceCountQuery.isLoading);
+
+ const metrics = useMemo(() => {
+ const summary = deviceCountQuery.data?.summary;
+ if (summary) {
+ return {
+ total: summary.total_monitors,
+ operational: summary.operational,
+ transmitting: summary.transmitting,
+ notTransmitting: summary.not_transmitting,
+ dataAvailable: summary.data_available,
+ };
+ }
+
+ return {
+ total: 0,
+ operational: 0,
+ transmitting: 0,
+ notTransmitting: 0,
+ dataAvailable: 0,
+ };
+ }, [deviceCountQuery.data]);
+
+ const router = useRouter();
+
+ const handleNavigation = (queryString: string) => {
+ const basePath = isPersonalScope ? "/devices/my-devices" : "/devices/overview";
+ router.push(`${basePath}${queryString}`);
+ };
+
+ return (
+
+
+ }
+ isLoading={isLoading}
+ onClick={() => handleNavigation('')}
+ variant="primary"
+ size="md"
+ />
+
+ }
+ isLoading={isLoading}
+ onClick={() => handleNavigation('?status=operational')}
+ variant="success"
+ size="md"
+ />
+
+ }
+ isLoading={isLoading}
+ onClick={() => handleNavigation('?status=transmitting')}
+ variant="info"
+ size="md"
+ />
+
+ }
+ isLoading={isLoading}
+ onClick={() => handleNavigation('?status=not_transmitting')}
+ variant="default"
+ size="md"
+ />
+
+ }
+ isLoading={isLoading}
+ onClick={() => handleNavigation('?status=data_available')}
+ variant="warning"
+ size="md"
+ />
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx
new file mode 100644
index 0000000000..16a920c32a
--- /dev/null
+++ b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import React, { useState } from "react";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { DatePicker } from "@/components/ui/date-picker";
+import { useAddMaintenanceLog } from "@/core/hooks/useDevices";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { Switch } from "@/components/ui/switch";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+interface AddMaintenanceLogModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ deviceName: string;
+}
+
+interface Option {
+ value: string
+ label: string
+}
+
+const createTagOption = (label: string): Option => ({
+ value: label.toLowerCase(),
+ label: label,
+})
+
+const DEFAULT_TAGS: Option[] = [
+ createTagOption("Dust blowing and sensor cleaning"),
+ createTagOption("Site update check"),
+ createTagOption("Device equipment check"),
+ createTagOption("Power circuitry and components works"),
+ createTagOption("GPS module works/replacement"),
+ createTagOption("GSM module works/replacement"),
+ createTagOption("Battery works/replacement"),
+ createTagOption("Power supply works/replacement"),
+ createTagOption("Antenna works/replacement"),
+ createTagOption("Mounts replacement"),
+ createTagOption("Software checks/re-installation"),
+ createTagOption("PCB works/replacement"),
+ createTagOption("Temp/humidity sensor works/replacement"),
+ createTagOption("Air quality sensor(s) works/replacement"),
+]
+
+const AddMaintenanceLogModal: React.FC = ({ open, onOpenChange, deviceName }) => {
+ const [date, setDate] = useState(new Date());
+ const [description, setDescription] = useState("");
+ const [selectedTags, setSelectedTags] = useState([])
+ const [maintenanceType, setMaintenanceType] = useState<"preventive" | "corrective">("preventive");
+
+ const { userDetails } = useUserContext();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const addMaintenanceLog = useAddMaintenanceLog();
+
+ // Reset form when modal opens/closes
+ React.useEffect(() => {
+ if (!open) {
+ setDate(new Date());
+ setDescription("");
+ setSelectedTags([]);
+ setMaintenanceType("preventive");
+ }
+ }, [open]);
+
+ const handleSubmit = async () => {
+ if (!date || selectedTags.length === 0) {
+ showBanner({ severity: 'error', message: 'Please fill all required fields', scoped: true });
+ return;
+ }
+
+ const logData = {
+ date: date.toISOString(),
+ tags: selectedTags.map(tag => tag.toLowerCase()),
+ description,
+ userName: userDetails?.email || "",
+ maintenanceType,
+ email: userDetails?.email || "",
+ firstName: userDetails?.firstName || "",
+ lastName: userDetails?.lastName || "",
+ user_id: userDetails?._id || "",
+ };
+
+ try {
+ await addMaintenanceLog.mutateAsync({ deviceName, logData });
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `Maintenance log for ${deviceName} has been added successfully.`,
+ scoped: false
+ }, 300);
+ onOpenChange(false);
+ } catch (error) {
+ showBanner({ severity: 'error', message: `Failed to Add Maintenance Log: ${getApiErrorMessage(error)}`, scoped: true });
+ }
+ };
+
+ return (
+ onOpenChange(false)}
+ title={`Add Maintenance Log for ${deviceName}`}
+ className="w-[70vw] h-[70vh] max-w-none max-h-none m-0 p-0"
+ primaryAction={{
+ label: addMaintenanceLog.isPending ? "Saving..." : "Save Log",
+ onClick: handleSubmit,
+ disabled: addMaintenanceLog.isPending,
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => onOpenChange(false),
+ variant: "outline",
+ disabled: addMaintenanceLog.isPending,
+ }}
+ >
+
+
+ Maintenance Type:
+ Preventive
+ setMaintenanceType(checked ? 'corrective' : 'preventive')}
+ />
+ Corrective
+
+
+ Date of Maintenance
+
+
+
+ Tags
+
+
+
+ Description
+
+
+
+ );
+};
+
+export default AddMaintenanceLogModal;
diff --git a/src/vertex-template/components/features/devices/bulk-edit-device-details-modal.tsx b/src/vertex-template/components/features/devices/bulk-edit-device-details-modal.tsx
new file mode 100644
index 0000000000..788e0fb253
--- /dev/null
+++ b/src/vertex-template/components/features/devices/bulk-edit-device-details-modal.tsx
@@ -0,0 +1,202 @@
+import React, { useMemo, useState } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { DEVICE_CATEGORIES } from "@/core/constants/devices";
+import { useUpdateDeviceBulk } from "@/core/hooks/useDevices";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+type EditableDeviceField =
+ | "category"
+ | "network"
+ | "visibility"
+ | "authRequired"
+ | "tags";
+
+type FieldValue = string | string[] | boolean | null;
+
+interface BulkEditDevicesModalProps {
+ open: boolean;
+ onClose: () => void;
+ deviceIds: string[];
+}
+
+export default function BulkEditDevicesModal({
+ open,
+ onClose,
+ deviceIds,
+}: BulkEditDevicesModalProps) {
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const bulkUpdate = useUpdateDeviceBulk({
+ onSuccess: () => {
+ showBannerWithDelay({ severity: 'success', message: 'Devices updated successfully.', scoped: false });
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Bulk update failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
+
+ const [step, setStep] = useState<"choose_field" | "confirm">("choose_field");
+ const [selectedField, setSelectedField] = useState(null);
+ const [value, setValue] = useState(null);
+
+ const fieldOptions = useMemo(
+ () => [
+ { label: "Category", value: "category" },
+ { label: "Network", value: "network" },
+ { label: "Auth Required", value: "authRequired" },
+ ],
+ []
+ );
+
+ const hasValidValue = useMemo(() => {
+ if (!selectedField) return false;
+ if (value === null) return false;
+
+ if (selectedField === "network") {
+ return typeof value === "string" && value.trim().length > 0;
+ }
+
+ if (selectedField === "tags") {
+ return Array.isArray(value);
+ }
+
+ return true;
+ }, [selectedField, value]);
+
+ const reset = () => {
+ setStep("choose_field");
+ setSelectedField(null);
+ setValue(null);
+ };
+
+ const handleClose = () => {
+ reset();
+ onClose();
+ };
+
+ const handleProceed = () => {
+ if (!selectedField || !hasValidValue) return;
+ setStep("confirm");
+ };
+
+ const handleSubmit = () => {
+ if (!selectedField || !hasValidValue || deviceIds.length === 0) return;
+
+ const updateData = { [selectedField]: value };
+
+ bulkUpdate.mutate({ deviceIds, updateData }, { onSuccess: () => handleClose() });
+ };
+
+ const renderFieldInput = () => {
+ switch (selectedField) {
+ case "category":
+ return (
+ setValue(e.target.value)}
+ >
+ {DEVICE_CATEGORIES.map((c) => (
+
+ {c.label}
+
+ ))}
+
+ );
+
+ case "network":
+ return (
+ setValue(e.target.value)}
+ />
+ );
+
+
+ case "authRequired":
+ return (
+ setValue(e.target.value === "true")}
+ >
+ True
+ False
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+ setStep("choose_field") }
+ }
+ primaryAction={{
+ label: step === "choose_field" ? "Continue" : "Confirm Update",
+ onClick: step === "choose_field" ? handleProceed : handleSubmit,
+ disabled:
+ step === "choose_field"
+ ? !selectedField || !hasValidValue
+ : bulkUpdate.isPending || !hasValidValue,
+ className: step === "confirm" ? "min-w-[140px]" : undefined,
+ }}
+ >
+ {step === "choose_field" && (
+
+
{
+ const newField = e.target.value as EditableDeviceField;
+ setSelectedField(newField);
+ setValue(null);
+ }}
+ >
+ Choose field
+ {fieldOptions.map((f) => (
+
+ {f.label}
+
+ ))}
+
+
+ {selectedField && (
+
{renderFieldInput()}
+ )}
+
+ )}
+
+ {step === "confirm" && (
+
+
+
You are about to update:
+
+ {selectedField} →{" "}
+
+ {JSON.stringify(value)}
+
+
+
+ This will affect {deviceIds.length} devices.
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/client-paginated-devices-table.tsx b/src/vertex-template/components/features/devices/client-paginated-devices-table.tsx
new file mode 100644
index 0000000000..e3dd3f38ef
--- /dev/null
+++ b/src/vertex-template/components/features/devices/client-paginated-devices-table.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { Device } from "@/app/types/devices";
+import { useRouter } from "next/navigation";
+import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable";
+import { useCallback, useMemo, useState } from "react";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { getColumns, type TableDevice } from "./utils/table-columns";
+import { Edit, Plus, Trash2 } from "lucide-react";
+import BulkEditDevicesModal from "./bulk-edit-device-details-modal";
+import { AssignCohortDevicesDialog } from "../cohorts/assign-cohort-devices";
+import { UnassignCohortDevicesDialog } from "../cohorts/unassign-cohort-devices";
+
+interface ClientPaginatedDevicesTableProps {
+ devices: Device[];
+ isLoading?: boolean;
+ error?: Error | null;
+ itemsPerPage?: number;
+ onDeviceClick?: (device: Device) => void;
+ multiSelect?: boolean;
+ className?: string;
+ hiddenColumns?: string[];
+}
+
+export default function ClientPaginatedDevicesTable({
+ devices,
+ isLoading = false,
+ error = null,
+ itemsPerPage = 25,
+ onDeviceClick,
+ multiSelect = false,
+ className,
+ hiddenColumns = [],
+}: ClientPaginatedDevicesTableProps) {
+ const router = useRouter();
+ const { userContext, activeGroup } = useUserContext();
+ const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo";
+
+ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]);
+ const [showAssignDialog, setShowAssignDialog] = useState(false);
+ const [showUnassignDialog, setShowUnassignDialog] = useState(false);
+ const [showBulkEditModal, setShowBulkEditModal] = useState(false);
+ const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]);
+
+ const handleAssignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowAssignDialog(false);
+ };
+
+ const handleUnassignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowUnassignDialog(false);
+ };
+
+ const handleAddCohortDeviceActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowAssignDialog(true);
+ },
+ []
+ );
+
+ const handleUnassignActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowUnassignDialog(true);
+ },
+ []
+ );
+
+ const handleBulkEditClose = () => {
+ setShowBulkEditModal(false);
+ setBulkEditDeviceIds([]);
+ };
+
+ const handleDeviceClick = (item: unknown) => {
+ const device = item as Device;
+ if (onDeviceClick) onDeviceClick(device);
+ else router.push(`/devices/overview/${device._id}`);
+ };
+
+ const devicesWithId: TableDevice[] = useMemo(() => {
+ return (devices || [])
+ .filter(
+ (device: Device): device is Device & { _id: string } =>
+ typeof device._id === "string" && device._id.trim() !== ""
+ )
+ .map((device) => ({
+ ...device,
+ id: device._id,
+ }));
+ }, [devices]);
+
+ const columns = useMemo(() => {
+ const allColumns = getColumns(isInternalView);
+ return allColumns.filter(
+ (column) => !hiddenColumns.includes(column.key as string)
+ );
+ }, [isInternalView, hiddenColumns]);
+
+ const actions = useMemo(() => {
+ if (!multiSelect) return [];
+
+ const baseActions: TableAction[] = [
+ {
+ label: "Add to Cohort",
+ value: "assign_cohort",
+ handler: handleAddCohortDeviceActionSubmit,
+ icon: Plus,
+ },
+ {
+ label: "Bulk Edit Devices",
+ value: "bulk_edit",
+ handler: (ids) => {
+ if (!ids.length) return;
+
+ const safeIds = ids.map(String);
+ setBulkEditDeviceIds(safeIds);
+ setShowBulkEditModal(true);
+ },
+ icon: Edit,
+ },
+ {
+ label: "Remove from Cohort",
+ value: "unassign_cohort",
+ handler: handleUnassignActionSubmit,
+ icon: Trash2,
+ }
+ ];
+
+ return baseActions;
+ }, [
+ multiSelect,
+ handleAddCohortDeviceActionSubmit,
+ handleUnassignActionSubmit,
+ ]);
+
+ return (
+
+
setSelectedDeviceObjects(items as TableDevice[])}
+ actions={actions}
+ emptyState={
+ error ? (
+
+
+
Unable to load devices
+
{error.message || "An unknown error occurred"}
+
+ ) : (
+ "No devices available"
+ )
+ }
+ searchable
+ searchableColumns={["long_name", "name", "description", "site.name"]}
+ />
+ {/* Assign to Cohort Dialog */}
+
+
+ {/* Unassign from Cohort Dialog */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/create-device-modal.tsx b/src/vertex-template/components/features/devices/create-device-modal.tsx
new file mode 100644
index 0000000000..57d8646f4b
--- /dev/null
+++ b/src/vertex-template/components/features/devices/create-device-modal.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import React, { useState } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { useCreateDevice } from "@/core/hooks/useDevices";
+import { useAppSelector } from "@/core/redux/hooks";
+import { DEVICE_CATEGORIES } from "@/core/constants/devices";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { DEFAULT_DEVICE_TAGS } from "@/core/constants/devices";
+import { Label } from "@/components/ui/label";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+interface CreateDeviceModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ networkName?: string;
+}
+
+const CreateDeviceModal: React.FC = ({
+ open,
+ onOpenChange,
+ networkName,
+}) => {
+ const [formData, setFormData] = useState({
+ long_name: "",
+ category: "",
+ description: "",
+ tags: [] as string[],
+ });
+
+ const [errors, setErrors] = useState>({});
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const activeNetwork = useAppSelector((state) => state.user.activeNetwork);
+ const createDevice = useCreateDevice();
+
+ const validateForm = () => {
+ const newErrors: Record = {};
+
+ if (!formData.long_name.trim()) {
+ newErrors.long_name = "Device name is required";
+ }
+
+ if (!formData.category) {
+ newErrors.category = "Category is required";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ const effectiveNetworkName = networkName || activeNetwork?.net_name;
+
+ if (!effectiveNetworkName) {
+ showBanner({ severity: 'error', message: 'No active Sensor Manufacturer found', scoped: true });
+ return;
+ }
+
+ try {
+ await createDevice.mutateAsync({
+ long_name: formData.long_name.trim(),
+ category: formData.category,
+ description: formData.description.trim() || undefined,
+ network: effectiveNetworkName,
+ tags: formData.tags,
+ }, {
+ onSuccess: (data, variables) => {
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${variables.long_name.trim()} has been created successfully.`,
+ scoped: false
+ }, 300);
+ setFormData({ long_name: "", category: "", description: "", tags: [] });
+ setErrors({});
+ onOpenChange(false);
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Creation Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
+ } catch (error) {
+ showBanner({ severity: 'error', message: `Creation Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ }
+ };
+
+ const handleInputChange = (field: string, value: string) => {
+ setFormData((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors((prev) => ({
+ ...prev,
+ [field]: "",
+ }));
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ };
+
+ React.useEffect(() => {
+ if (!open) {
+ setFormData({
+ long_name: "",
+ category: "",
+ description: "",
+ tags: [],
+ });
+ setErrors({});
+ }
+ }, [open]);
+
+ return (
+
+
+
handleInputChange("long_name", e.target.value)}
+ placeholder="Enter device name"
+ error={errors.long_name}
+ required
+ />
+ handleInputChange("category", e.target.value)}
+ placeholder="Select device category"
+ error={errors.category}
+ required
+ >
+ {DEVICE_CATEGORIES.map((category) => (
+
+ {category.label}
+
+ ))}
+
+ handleInputChange("description", e.target.value)}
+ placeholder="Enter device description"
+ rows={3}
+ />
+
+ Tags (Optional)
+ setFormData((prev) => ({ ...prev, tags }))}
+ allowCreate={true}
+ />
+
+
+
+ );
+};
+
+export default CreateDeviceModal;
diff --git a/src/vertex-template/components/features/devices/deploy-device-component.tsx b/src/vertex-template/components/features/devices/deploy-device-component.tsx
new file mode 100644
index 0000000000..8b3d822124
--- /dev/null
+++ b/src/vertex-template/components/features/devices/deploy-device-component.tsx
@@ -0,0 +1,861 @@
+"use client";
+
+import * as React from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { format } from "date-fns";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
+
+import { AqCalendar, AqChevronDown, AqChevronUp } from "@airqo/icons-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+} from "@/components/ui/card";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { Label } from "@/components/ui/label";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { Checkbox } from "@/components/ui/checkbox";
+import { cn } from "@/lib/utils";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useDeviceDetails, useDevices, useDeployDevice } from "@/core/hooks/useDevices";
+import { ComboBox } from "@/components/ui/combobox";
+import { Device, type DevicePreviousSite } from "@/app/types/devices";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import LocationAutocomplete from "@/components/features/location-autocomplete/LocationAutocomplete";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import dynamic from "next/dynamic";
+
+const MiniMap = React.lazy(() => import("../mini-map/mini-map"));
+const ClaimDeviceModal = dynamic(
+ () => import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
+
+interface MountTypeOption {
+ value: string;
+ label: string;
+}
+
+interface PowerTypeOption {
+ value: string;
+ label: string;
+}
+
+interface DeviceData {
+ deviceName: string;
+ height: string;
+ deployment_date: Date | undefined;
+ mountType: string;
+ powerType: string;
+ isPrimarySite: boolean;
+ latitude: string;
+ longitude: string;
+ siteName: string;
+ site_id: string;
+ network: string;
+}
+
+interface DeviceDetailsStepProps {
+ deviceData: DeviceData;
+ onInputChange: (e: React.ChangeEvent) => void;
+ onSelectChange: (name: string) => (value: string) => void;
+ onDateChange: (date: Date | undefined) => void;
+ onCheckboxChange: (checked: boolean) => void;
+ availableDevices: Device[];
+ onDeviceSelect: (deviceName: string) => void;
+ onClaimDevice: () => void;
+ isLoadingDevices: boolean;
+ isDevicePrefilled: boolean;
+}
+
+interface DeploymentTypeStepProps {
+ deviceData: DeviceData;
+ siteSource: 'new' | 'previous';
+ onSiteSourceChange: (value: 'new' | 'previous') => void;
+ previousSites: Array<{ id: string; name: string; latitude?: number; longitude?: number }>;
+ previousSitesDisabled: boolean;
+ onPreviousSiteSelect: (siteId: string) => void;
+}
+
+interface LocationStepProps {
+ deviceData: DeviceData;
+ onCoordinateChange: (lat: string, lng: string) => void;
+ onSiteNameChange: (name: string) => void;
+ inputMode: 'siteName' | 'coordinates';
+ onToggleInputMode: () => void;
+}
+
+interface DeployDeviceComponentProps {
+ prefilledDevice?: Device;
+ onClose?: () => void;
+ availableDevices?: Device[];
+ onDeploymentSuccess?: () => void;
+ onDeploymentError?: (error: Error) => void;
+}
+
+const mountTypeOptions: MountTypeOption[] = [
+ { value: "faceboard", label: "Faceboard" },
+ { value: "pole", label: "Pole" },
+ { value: "rooftop", label: "Rooftop" },
+ { value: "suspended", label: "Suspended" },
+ { value: "wall", label: "Wall" },
+];
+
+const powerTypeOptions: PowerTypeOption[] = [
+ { value: "solar", label: "Solar" },
+ { value: "mains", label: "Mains" },
+ { value: "alternator", label: "Alternator" },
+];
+
+const fetchClaimedDevices = async (): Promise => {
+ const res = await fetch("/api/v2/devices/my-devices?claim_status=claimed");
+ if (!res.ok) throw new Error('Failed to fetch devices');
+ const data = await res.json();
+ return data.devices || [];
+};
+
+const DeviceDetailsStep = ({
+ deviceData,
+ onInputChange,
+ onSelectChange,
+ onDateChange,
+ onCheckboxChange,
+ availableDevices,
+ onDeviceSelect,
+ onClaimDevice,
+ isLoadingDevices,
+ isDevicePrefilled,
+}: DeviceDetailsStepProps) => {
+ const { networks, isLoading: isLoadingNetworks, error: networksError } = useNetworks();
+
+ return (
+
+
+ Device to Deploy
+ ({
+ value: dev.name,
+ label: dev.long_name || dev.name,
+ }))}
+ value={deviceData.deviceName}
+ onValueChange={onDeviceSelect}
+ placeholder={isLoadingDevices ? "Loading devices..." : "Select or type device name"}
+ searchPlaceholder="Search or type device name..."
+ emptyMessage="No devices found"
+ disabled={isLoadingDevices || isDevicePrefilled}
+ allowCustomInput={!isDevicePrefilled}
+ onCustomAction={!isDevicePrefilled ? onClaimDevice : undefined}
+ customActionLabel={!isDevicePrefilled ? "Device not listed? Claim a new device" : undefined}
+ className="w-full"
+ />
+
+
+ onSelectChange("network")(e.target.value)}
+ placeholder={isLoadingNetworks ? "Loading Sensor Manufacturers..." : "Select a Sensor Manufacturer"}
+ disabled={true}
+ error={networksError ? "Failed to load Sensor Manufacturers" : undefined}
+ >
+ {networks.map((network) => (
+
+ {network.net_name}
+
+ ))}
+
+
+
+
Deployment Date
+
+
+
+
+ {deviceData.deployment_date ? (
+ format(deviceData.deployment_date, "PPP")
+ ) : (
+ Pick a date
+ )}
+
+
+
+ date > new Date() || date < new Date("1900-01-01")}
+ />
+
+
+
+
+
onSelectChange("mountType")(e.target.value)}
+ placeholder="Select mount type"
+ >
+ {mountTypeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
onSelectChange("powerType")(e.target.value)}
+ placeholder="Select power type"
+ >
+ {powerTypeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ onCheckboxChange(checked === true)}
+ />
+ Primary Site
+
+
+ );
+};
+
+const DeploymentTypeStep = ({
+ deviceData,
+ siteSource,
+ onSiteSourceChange,
+ previousSites,
+ previousSitesDisabled,
+ onPreviousSiteSelect,
+}: DeploymentTypeStepProps) => {
+ return (
+
+
+
+
onSiteSourceChange(e.target.value as 'new' | 'previous')}
+ >
+ New site
+
+ Previous site
+
+
+ {previousSitesDisabled && (
+
+ No previous sites available for this device.
+
+ )}
+
+
+ {siteSource === 'previous' && (
+
+ onPreviousSiteSelect(e.target.value)}
+ >
+
+ Select a site
+
+ {previousSites.map((site) => (
+
+ {site.name}
+
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+const LocationStep = ({
+ deviceData,
+ onCoordinateChange,
+ onSiteNameChange,
+ inputMode,
+ onToggleInputMode
+}: LocationStepProps) => {
+
+ return (
+
+
+
+ Location Input Mode
+
+ Switch to {inputMode === 'siteName' ? 'Coordinates' : 'Site Name'}
+
+
+
+ {inputMode === 'siteName' ? (
+
+
Site Name
+
{
+ onSiteNameChange(location.name);
+ onCoordinateChange(location.latitude.toString(), location.longitude.toString());
+ }}
+ placeholder="Search for a location"
+ />
+
+ Search and select a location to automatically set coordinates
+
+
+ ) : (
+
+
+ onCoordinateChange(e.target.value, deviceData.longitude)}
+ />
+ onCoordinateChange(deviceData.latitude, e.target.value)}
+ />
+
+
onSiteNameChange(e.target.value)}
+ description="Enter a custom name for this site location. This will be used as a fallback if the map cannot determine a location name automatically."
+ />
+
+ )}
+
+
+
Interactive Map
+
+ Click on the map to set location or drag the marker. The site name will be automatically
+ updated with the location name from Mapbox when you interact with the map.
+ {inputMode === 'siteName'
+ ? ' You can also search for locations by name.'
+ : ' Switch to Site Name mode to search by location name.'}
+
+
}>
+
+
+
+
+
+ );
+};
+
+// StepCard component for collapsible step cards
+interface StepCardProps {
+ title: string;
+ stepIndex: number;
+ currentStep: number;
+ onHeaderClick: (stepIndex: number) => void;
+ footer?: React.ReactNode;
+ children: React.ReactNode;
+}
+const StepCard: React.FC = ({ title, stepIndex, currentStep, onHeaderClick, children, footer }) => (
+
+
+
+ onHeaderClick(stepIndex)}>
+
+ {title}
+ {currentStep === stepIndex ? : }
+
+
+
+
+ {children}
+ {footer && {footer} }
+
+
+
+);
+
+const DeployDeviceComponent = ({
+ prefilledDevice,
+ onClose,
+ availableDevices: externalAvailableDevices = [],
+ onDeploymentSuccess,
+ onDeploymentError
+}: DeployDeviceComponentProps) => {
+ const queryClient = useQueryClient();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { userScope, userDetails } = useUserContext();
+ const { devices: allDevices } = useDevices({ enabled: userScope !== 'personal' });
+ const [currentStep, setCurrentStep] = React.useState(0);
+ const [inputMode, setInputMode] = React.useState<'siteName' | 'coordinates'>('siteName');
+ const [siteSource, setSiteSource] = React.useState<'new' | 'previous'>('new');
+ const [isClaimModalOpen, setIsClaimModalOpen] = React.useState(false);
+
+ const [deviceData, setDeviceData] = React.useState({
+ deviceName: prefilledDevice?.name ?? "",
+ deployment_date: undefined,
+ height: prefilledDevice?.height?.toString() ?? "",
+ mountType: prefilledDevice?.mountType ?? "",
+ powerType: prefilledDevice?.powerType ?? "",
+ isPrimarySite: prefilledDevice?.isPrimaryInLocation ?? false,
+ latitude: prefilledDevice?.latitude?.toString() ?? "",
+ longitude: prefilledDevice?.longitude?.toString() ?? "",
+ siteName: prefilledDevice?.site_name ?? "",
+ site_id: "",
+ network: prefilledDevice?.network ?? "airqo",
+ });
+
+ // Use external availableDevices if provided, otherwise use internal filtering
+ const filteredAirQoDevices = React.useMemo(() => {
+ if (externalAvailableDevices.length > 0) return externalAvailableDevices;
+ if (userScope === 'personal') return [];
+ return allDevices.filter(
+ (dev: Device) => dev.status === "not deployed" || dev.status === "recalled"
+ );
+ }, [externalAvailableDevices, userScope, allDevices]);
+
+ // Fetch claimed devices for personal context
+ const { data: claimedDevices = [], isLoading: isLoadingClaimedDevices, refetch: refetchDevices } = useQuery({
+ queryKey: ['claimedDevices'],
+ queryFn: fetchClaimedDevices,
+ enabled: userScope === 'personal', // Only fetch when in personal scope
+ refetchOnWindowFocus: true,
+ });
+
+ // Choose which devices to show based on scope
+ const availableDevices = userScope === 'personal' ? claimedDevices : filteredAirQoDevices;
+ const isLoadingDevices = userScope === 'personal' ? isLoadingClaimedDevices : false;
+
+ const devicesForSelection = React.useMemo(() => {
+ if (prefilledDevice) {
+ // Check if the prefilled device is already in the list of available devices
+ const isDeviceInList = availableDevices.some(
+ (device: Device) => device.name === prefilledDevice.name,
+ );
+ // If not, add it to the beginning of the list for display purposes
+ if (!isDeviceInList) {
+ return [prefilledDevice, ...availableDevices];
+ }
+ }
+ return availableDevices;
+ }, [prefilledDevice, availableDevices]);
+
+
+ const selectedDeviceId = React.useMemo(() => {
+ if (prefilledDevice?._id) return prefilledDevice._id;
+ const selected = devicesForSelection.find((d: Device) => d.name === deviceData.deviceName);
+ return selected?._id;
+ }, [prefilledDevice?._id, devicesForSelection, deviceData.deviceName]);
+
+ const { data: selectedDeviceDetailsResponse } = useDeviceDetails(selectedDeviceId || "");
+ const selectedDeviceDetails =
+ (selectedDeviceDetailsResponse?.data as Device | undefined) ?? prefilledDevice;
+
+ const previousSites = React.useMemo(() => {
+ const raw = selectedDeviceDetails?.previous_sites || [];
+
+ return raw
+ .map((s): { id: string; name: string; latitude?: number; longitude?: number } | null => {
+ if (typeof s === "string") {
+ const id = s.trim();
+ return id ? { id, name: id } : null;
+ }
+
+ const site = s as DevicePreviousSite;
+ const id = typeof site._id === "string" ? site._id.trim() : "";
+ if (!id) return null;
+
+ const name =
+ (typeof site.name === "string" && site.name.trim()) ||
+ (typeof site.location_name === "string" && site.location_name.trim()) ||
+ (typeof site.search_name === "string" && site.search_name.trim()) ||
+ id;
+
+ return {
+ id,
+ name,
+ latitude: typeof site.latitude === "number" ? site.latitude : undefined,
+ longitude: typeof site.longitude === "number" ? site.longitude : undefined,
+ };
+ })
+ .filter((v): v is { id: string; name: string; latitude?: number; longitude?: number } => v !== null);
+ }, [selectedDeviceDetails?.previous_sites]);
+
+ const previousSitesDisabled = previousSites.length === 0;
+
+ const lastDeviceIdRef = React.useRef(undefined);
+ React.useEffect(() => {
+ if (!selectedDeviceId) return;
+ if (lastDeviceIdRef.current && lastDeviceIdRef.current !== selectedDeviceId) {
+ setSiteSource("new");
+ setDeviceData((prev) => ({ ...prev, site_id: "" }));
+ }
+ lastDeviceIdRef.current = selectedDeviceId;
+ }, [selectedDeviceId]);
+
+ React.useEffect(() => {
+ if (siteSource !== 'previous' || !deviceData.site_id) return;
+ const selectedSite = previousSites.find(s => s.id === deviceData.site_id);
+ if (!selectedSite) return;
+
+ setDeviceData((prev) => ({
+ ...prev,
+ siteName: selectedSite.name ? selectedSite.name : prev.siteName,
+ latitude: typeof selectedSite.latitude === "number"
+ ? selectedSite.latitude.toString()
+ : prev.latitude,
+ longitude: typeof selectedSite.longitude === "number"
+ ? selectedSite.longitude.toString()
+ : prev.longitude,
+ }));
+ }, [siteSource, deviceData.site_id, previousSites]);
+
+ const handleInputChange = (e: React.ChangeEvent): void => {
+ const { name, value } = e.target;
+ setDeviceData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSelectChange =
+ (name: string) =>
+ (value: string): void => {
+ setDeviceData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleDateChange = (date: Date | undefined): void => {
+ setDeviceData((prev) => ({ ...prev, deployment_date: date }));
+ };
+
+ const handleDeviceSelect = (deviceName: string) => {
+ const selectedDevice = devicesForSelection.find((d: Device) => d.name === deviceName);
+ setDeviceData((prev) => ({
+ ...prev,
+ deviceName,
+ network: selectedDevice?.network || prev.network || "airqo",
+ }));
+ };
+
+ const handleClaimDevice = () => {
+ setIsClaimModalOpen(true);
+ };
+
+ const handleCheckboxChange = (checked: boolean): void => {
+ setDeviceData((prev) => ({ ...prev, isPrimarySite: checked }));
+ };
+
+ const handleCoordinateChange = (lat: string, lng: string): void => {
+ setDeviceData((prev) => ({ ...prev, latitude: lat, longitude: lng }));
+ };
+
+ const handleSiteNameChange = (name: string): void => {
+ setDeviceData((prev) => ({ ...prev, siteName: name }));
+ };
+
+ const toggleInputMode = (): void => {
+ setInputMode((prev) => prev === 'siteName' ? 'coordinates' : 'siteName');
+ };
+
+ const handleNext = (): void => {
+ if (currentStep === 0 && !validateDeviceDetails()) {
+ showBanner({ severity: 'error', message: 'Incomplete Details: Please fill in all required device details.', scoped: true });
+ return;
+ }
+ setCurrentStep((prev) => Math.min(prev + 1, siteSource === 'new' ? 2 : 1));
+ };
+
+ const handleBack = (): void => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ };
+
+ const validateDeviceDetails = (): boolean => {
+ return Boolean(
+ deviceData.deviceName &&
+ deviceData.deployment_date &&
+ deviceData.height &&
+ deviceData.mountType &&
+ deviceData.powerType
+ );
+ };
+
+ const validateLocation = (): boolean => {
+ if (siteSource === 'previous') {
+ return Boolean(deviceData.site_id);
+ }
+ return Boolean(deviceData.latitude && deviceData.longitude);
+ };
+
+ const deployDevice = useDeployDevice();
+
+ const handleDeploy = (): void => {
+ if (!userDetails?._id) {
+ showBanner({ severity: 'error', message: 'User information not available. Please reload the page.', scoped: true });
+ return;
+ }
+
+ if (siteSource === 'previous' && !deviceData.site_id) {
+ showBanner({ severity: 'error', message: 'Select a previous site to continue.', scoped: true });
+ return;
+ }
+
+ const selectedPreviousSite = previousSites.find(s => s.id === deviceData.site_id);
+ const previousSiteName = selectedPreviousSite?.name || deviceData.siteName || `${deviceData.deviceName} Site`;
+
+ deployDevice.mutate(
+ {
+ deviceName: deviceData.deviceName,
+ deployment_date: deviceData.deployment_date?.toISOString(),
+ height: deviceData.height,
+ mountType: deviceData.mountType,
+ powerType: deviceData.powerType,
+ isPrimaryInLocation: deviceData.isPrimarySite,
+ latitude: deviceData.latitude || "",
+ longitude: deviceData.longitude || "",
+ ...(siteSource === 'previous'
+ ? { site_id: deviceData.site_id, site_name: previousSiteName }
+ : { site_name: deviceData.siteName || `${deviceData.deviceName} Site` }),
+ network: deviceData.network || "airqo",
+ user_id: userDetails._id,
+ firstName: userDetails.firstName,
+ lastName: userDetails.lastName,
+ email: userDetails.email,
+ userName: userDetails.userName,
+ },
+ {
+ onSuccess: () => {
+ if (prefilledDevice?._id) {
+ queryClient.invalidateQueries({ queryKey: ["device-details", prefilledDevice._id] });
+ }
+
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${deviceData.deviceName} has been deployed successfully.`,
+ scoped: false
+ }, 300);
+
+ setDeviceData({
+ deviceName: "",
+ deployment_date: undefined,
+ height: "",
+ mountType: "",
+ powerType: "",
+ isPrimarySite: false,
+ latitude: "",
+ longitude: "",
+ siteName: "",
+ site_id: "",
+ network: "airqo",
+ });
+
+ setCurrentStep(0);
+ setInputMode("siteName");
+ setSiteSource("new");
+
+ onDeploymentSuccess?.();
+ if (onClose) onClose();
+ },
+ onError: (error) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const errorData = (error as any)?.response?.data;
+ let errorMessage = getApiErrorMessage(error);
+
+ if (errorData?.failed_deployments?.length > 0) {
+ const failedMessages = errorData.failed_deployments
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .map((deployment: any) => deployment.error?.message)
+ .filter(Boolean);
+
+ if (failedMessages.length > 0) {
+ errorMessage = failedMessages.join(', ');
+ }
+ }
+
+ showBanner({ severity: 'error', message: `Deployment Failed: ${errorMessage}`, scoped: true });
+ onDeploymentError?.(error instanceof Error ? error : new Error("Unknown error"));
+ },
+ }
+ );
+ };
+
+ // Update handleSectionClick to toggle open/close
+ const handleSectionClick = (stepIndex: number) => {
+ setCurrentStep((prev) => (prev === stepIndex ? -1 : stepIndex));
+ };
+
+ // In DeployDeviceComponent, define steps array and map over it
+ const steps = [
+ {
+ title: "Enter Device Details",
+ content: (
+
+ ),
+ footer: (
+ Next
+ ),
+ },
+ {
+ title: "Select Deployment Type",
+ content: (
+ {
+ if (value === 'previous' && previousSitesDisabled) return;
+ setSiteSource(value);
+ if (value === 'new') {
+ setDeviceData((prev) => ({ ...prev, site_id: "" }));
+ }
+ }}
+ previousSites={previousSites}
+ previousSitesDisabled={previousSitesDisabled}
+ onPreviousSiteSelect={(siteId) => setDeviceData((prev) => ({ ...prev, site_id: siteId }))}
+ />
+ ),
+ footer: (
+ <>
+ Back
+ {siteSource === 'previous' ? (
+
+ {deployDevice.isPending ? "Deploying..." : "Deploy"}
+
+ ) : (
+ Next
+ )}
+ >
+ ),
+ },
+ ...(siteSource === 'new' ? [
+ {
+ title: "Set Deployment Location",
+ content: (
+
+ ),
+ footer: (
+ <>
+ Back
+
+ {deployDevice.isPending ? "Deploying..." : "Deploy"}
+
+ >
+ ),
+ }
+ ] : [])
+ ];
+
+ return (
+
+ {/* Main Steps Column */}
+
+ {steps.map((step, idx) => (
+
+ {step.content}
+
+ ))}
+
+
+
{
+ setIsClaimModalOpen(false);
+ if (userScope === 'personal') {
+ refetchDevices();
+ } else {
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ }
+ }}
+ />
+
+ );
+};
+
+export default DeployDeviceComponent;
diff --git a/src/vertex-template/components/features/devices/deploy-device-modal.tsx b/src/vertex-template/components/features/devices/deploy-device-modal.tsx
new file mode 100644
index 0000000000..e1b490052f
--- /dev/null
+++ b/src/vertex-template/components/features/devices/deploy-device-modal.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import DeployDeviceComponent from "./deploy-device-component";
+import { Device } from "@/app/types/devices";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+
+interface DeployDeviceModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ device?: Device;
+}
+
+const DeployDeviceModal: React.FC = ({ open, onOpenChange, device }) => {
+ const handleClose = () => {
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default DeployDeviceModal;
diff --git a/src/vertex-template/components/features/devices/device-activity-item.tsx b/src/vertex-template/components/features/devices/device-activity-item.tsx
new file mode 100644
index 0000000000..85b5268494
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-activity-item.tsx
@@ -0,0 +1,130 @@
+import React, { useState } from "react";
+import { format, parseISO, isValid } from "date-fns";
+import { AqCopy01, AqChevronDown, AqChevronUp, AqMonitor } from "@airqo/icons-react";
+import { DeviceActivity } from "@/core/apis/devices";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useClipboard } from "@/core/hooks/useClipboard";
+
+interface DeviceActivityItemProps {
+ activity: DeviceActivity;
+ isLast: boolean;
+ showDeviceName?: boolean;
+ previousSiteId?: string;
+ previousSiteName?: string;
+}
+
+const DeviceActivityItem: React.FC = ({
+ activity,
+ isLast,
+ showDeviceName = false,
+ previousSiteId,
+ previousSiteName,
+}) => {
+ const parsedDate =
+ typeof activity.date === "string" ? parseISO(activity.date) : null;
+ const hasValidDate = parsedDate !== null && isValid(parsedDate);
+ const isRecall =
+ activity.activityType === "recall" ||
+ (typeof activity.description === "string" && /recalled/i.test(activity.description));
+
+ const [isPreviousSiteOpen, setIsPreviousSiteOpen] = useState(false);
+ const { handleCopy } = useClipboard();
+
+ return (
+
+ {/* Vertical Line */}
+ {!isLast && (
+
+ )}
+
+ {/* Icon Container */}
+
+
+ {/* Content */}
+
+
+
+
+ {hasValidDate
+ ? format(parsedDate, "MMM d, yyyy h:mm a")
+ : (typeof activity.date === "string" && activity.date.trim()
+ ? activity.date
+ : "Unknown date")}
+
+
+
+ {activity.description.charAt(0).toUpperCase() + activity.description.slice(1)}
+
+ {activity.activity_by && (
+
+ By {activity.activity_by.name || activity.activity_by.email}
+
+ )}
+
+ {/* Extra details (Placeholders for now, based on image "Added a file" etc) */}
+ {activity.deployment_type && activity.activityType === "deployment" && (
+
+ Deployment Type: {activity.deployment_type}
+
+ )}
+ {showDeviceName && activity.device && (
+
+ Device: {activity.device}
+
+ )}
+ {isRecall && previousSiteId && previousSiteName && (
+
+
+ Previous site
+
+
+
setIsPreviousSiteOpen((v) => !v)}
+ >
+ {previousSiteName}
+
+ {isPreviousSiteOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {isPreviousSiteOpen && (
+
+
+ Site ID
+
+
+
+ {previousSiteId}
+
+ {
+ handleCopy(previousSiteId);
+ }}
+ className="p-1"
+ Icon={AqCopy01}
+ aria-label="Copy site ID"
+ />
+
+
+ )}
+
+ )}
+ {/* If we had file attachments in the activity object, we would render them here */}
+
+
+
+ );
+};
+
+export default DeviceActivityItem;
diff --git a/src/vertex-template/components/features/devices/device-assignment-modal.tsx b/src/vertex-template/components/features/devices/device-assignment-modal.tsx
new file mode 100644
index 0000000000..c5eb0107a8
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-assignment-modal.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { useAppSelector } from "@/core/redux/hooks";
+import { Device } from "@/app/types/devices";
+import { useAssignDeviceToOrganization } from "@/core/hooks/useDevices";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { Label } from "@/components/ui/label";
+import { ComboBox } from "@/components/ui/combobox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import dynamic from "next/dynamic";
+
+const ClaimDeviceModal = dynamic(
+ () => import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
+
+interface DeviceAssignmentModalProps {
+ devices: Device[];
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ isLoadingDevices: boolean;
+}
+
+const DeviceAssignmentModal: React.FC = ({
+ devices,
+ isOpen,
+ onClose,
+ onSuccess,
+ isLoadingDevices,
+}) => {
+ const { userDetails, userGroups } = useAppSelector((state) => state.user);
+ const [selectedOrganization, setSelectedOrganization] = useState(null);
+ const [selectedDevice, setSelectedDevice] = useState("");
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
+
+ const assignDevice = useAssignDeviceToOrganization();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const handleAssign = async () => {
+ if (!selectedOrganization || !selectedDevice || !userDetails?._id) return;
+
+ try {
+ const data = await assignDevice.mutateAsync({
+ device_name: selectedDevice,
+ organization_id: selectedOrganization,
+ user_id: userDetails._id,
+ });
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${data.device.name} has been assigned successfully.`,
+ }, 300);
+ onSuccess();
+ } catch (error) {
+ showBanner({ severity: 'error', message: `Assignment Failed: ${getApiErrorMessage(error)}`, scoped: false });
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedOrganization(null);
+ setSelectedDevice(""); // Reset device selection
+ onClose();
+ };
+
+ const handleClaimDevice = () => {
+ setIsClaimModalOpen(true);
+ };
+
+ const handleSelectChange = (value: string) => {
+ setSelectedOrganization(value);
+ };
+
+ return (
+
+
+
+ Share Device
+
+ Choose an organization to share a device with. Organization members will be able to
+ view and monitor this device.
+
+
+
+
+
+ Choose Organization
+
+
+
+
+
+ {userGroups && userGroups.map((group) => (
+
+ {group.grp_title}
+
+ ))}
+
+
+
+
+
+ Choose Device
+ ({
+ value: dev.long_name || dev.name,
+ label: dev.long_name || dev.name,
+ }))}
+ value={selectedDevice}
+ onValueChange={(e) => setSelectedDevice(e)}
+ placeholder={isLoadingDevices ? "Loading devices..." : "Select or type device name"}
+ searchPlaceholder="Search or type device name..."
+ emptyMessage="No devices found"
+ disabled={isLoadingDevices}
+ allowCustomInput={true}
+ onCustomAction={handleClaimDevice}
+ customActionLabel="Device not listed? Claim a new device"
+ className="w-full"
+ />
+
+
+ {selectedOrganization && (
+
+
What happens when you share:
+
+ • Organization members can view device data and status
+ • You retain full ownership and control
+ • You can unshare the device at any time
+ • Device will appear in organization's device list
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {assignDevice.isPending ? "Sharing..." : "Share Device"}
+
+
+
+ setIsClaimModalOpen(false)}
+ />
+
+ );
+};
+
+export { DeviceAssignmentModal };
diff --git a/src/vertex-template/components/features/devices/device-category-card.tsx b/src/vertex-template/components/features/devices/device-category-card.tsx
new file mode 100644
index 0000000000..88192b9182
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-category-card.tsx
@@ -0,0 +1,75 @@
+
+import { Card } from "@/components/ui/card";
+import { Info, Car, MapPin } from "lucide-react";
+import React from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Device } from "@/app/types/devices";
+import { AqPuzzlePiece02 } from "@airqo/icons-react";
+
+interface DeviceCategoryCardProps {
+ device: Device;
+}
+
+const DeviceCategoryCard: React.FC = ({ device }) => {
+ const categories = device.device_categories;
+ const isDeployed = !!categories?.deployment_category;
+
+ if (!categories) return null;
+
+ const Icon = isDeployed
+ ? (categories.deployment_category?.toLowerCase().includes('mobile') ? Car : MapPin)
+ : AqPuzzlePiece02;
+
+ return (
+
+
+ {/* --- Info Icon Tooltip --- */}
+ {isDeployed && (
+
+
+
+
+
+
+
+ Mount Type
+ {categories.category_relationships?.note}
+
+
+ )}
+
+
+
+
+
+ {isDeployed ? (
+ {categories.deployment_category} Device
+ ) : (
+
+ Device not deployed. You will see deployment category here
+
+ )}
+
+
+ {isDeployed && (
+
+ {categories.category_hierarchy?.find(h => h.category === categories.deployment_category)?.description || 'Deployment Category'}
+
+ )}
+
+
+
+
+ );
+};
+
+export default DeviceCategoryCard;
diff --git a/src/vertex-template/components/features/devices/device-details-card.tsx b/src/vertex-template/components/features/devices/device-details-card.tsx
new file mode 100644
index 0000000000..c6b9247f48
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-details-card.tsx
@@ -0,0 +1,138 @@
+import { Card } from "@/components/ui/card";
+import { useDeviceDetails, useUpdateDeviceLocal } from "@/core/hooks/useDevices";
+import { Loader2 } from "lucide-react";
+import React, { useState, useEffect } from "react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { Badge } from "@/components/ui/badge";
+import { AqEdit01 } from "@airqo/icons-react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { DEFAULT_DEVICE_TAGS } from "@/core/constants/devices";
+
+interface DeviceDetailsCardProps {
+ deviceId: string;
+ onShowDetailsModal: () => void;
+}
+
+const DeviceDetailsCard: React.FC = ({ deviceId, onShowDetailsModal }) => {
+ const { data: deviceResponse, isLoading, error } = useDeviceDetails(deviceId);
+ const device = deviceResponse?.data;
+ const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const { mutate: updateDevice, isPending } = useUpdateDeviceLocal();
+
+ useEffect(() => {
+ setSelectedTags(device?.tags ?? []);
+ }, [device?.tags]);
+
+ const handleConfirmTagsUpdate = () => {
+ updateDevice(
+ { deviceId, deviceData: { tags: selectedTags } },
+ {
+ onSuccess: () => {
+ setIsTagsDialogOpen(false);
+ },
+ }
+ );
+ };
+
+ if (isLoading) {
+ return ;
+ }
+ if (error || !device) {
+ return Error loading device details. ;
+ }
+
+ return (
+
+
+
Device Details
+
+
+
Device Name
+
{device.long_name || device.name}
+
+
+
+
+ Tags
+
+
setIsTagsDialogOpen(true)}
+ className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full h-fit w-fit"
+ Icon={AqEdit01}
+ aria-label="Edit tags"
+ />
+
+
+ {device.tags && device.tags.length > 0 ? (
+ device.tags.map((tag) => (
+
+ {tag}
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
Deployment Status
+
{device.status}
+
+
+
Sensor Manufacturer
+
{device.network || 'N/A'}
+
+
+
+
+
+ View more details
+
+
+
+ !isPending && setIsTagsDialogOpen(false)}
+ title="Edit Device Tags"
+ size="md"
+ customFooter={
+
+ setIsTagsDialogOpen(false)}
+ disabled={isPending}
+ >
+ Cancel
+
+
+ {isPending ? "Updating..." : "Save Changes"}
+
+
+ }
+ >
+
+
+
+
+
+ );
+};
+
+export default DeviceDetailsCard;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/device-details-layout.tsx b/src/vertex-template/components/features/devices/device-details-layout.tsx
new file mode 100644
index 0000000000..db9df49a45
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-details-layout.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useDeviceDetails } from "@/core/hooks/useDevices";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { XCircle } from "lucide-react";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { getElapsedDurationMapper } from "@/lib/utils";
+import { useState } from "react";
+import DeviceDetailsModal from "@/components/features/devices/device-details-modal";
+import DeployDeviceModal from "@/components/features/devices/deploy-device-modal";
+import DeviceDetailsCard from "@/components/features/devices/device-details-card";
+import DeviceMeasurementsApiCard from "@/components/features/devices/device-measurements-api-card";
+import OnlineStatusCard from "@/components/features/devices/online-status-card";
+import RunDeviceTestCard from "@/components/features/devices/run-device-test-card";
+import RecallDeviceDialog from "@/components/features/devices/recall-device-dialog";
+import AddMaintenanceLogModal from "@/components/features/devices/add-maintenance-log-modal";
+import { Device } from "@/app/types/devices";
+import { DeviceLocationCard } from "@/components/features/devices/device-location-card";
+import MaintenanceStatusCard from "@/components/features/devices/maintenance-status-card";
+import DeviceCategoryCard from "@/components/features/devices/device-category-card";
+import DeviceHistoryCard from "@/components/features/devices/device-history-card";
+import { AqArrowLeft, AqSignal02, AqTool01 } from "@airqo/icons-react";
+import { usePageTitle } from "@/context/page-title-context";
+
+const ActionButtonsSkeleton = () => (
+
+);
+
+const ContentGridSkeleton = () => (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+);
+
+interface DeviceDetailsLayoutProps {
+ deviceId: string;
+}
+
+export default function DeviceDetailsLayout({ deviceId }: DeviceDetailsLayoutProps) {
+ const { data: deviceResponse, isLoading, error } = useDeviceDetails(deviceId);
+ const device = (deviceResponse?.data as Device | undefined) ?? undefined;
+ const router = useRouter();
+ usePageTitle({
+ title: device?.long_name || device?.name || "Device Details",
+ section: "Devices",
+ });
+
+ const deploymentStatus = device?.status || "unknown";
+
+ const [showDetailsModal, setShowDetailsModal] = useState(false);
+ const [showRecallDialog, setShowRecallDialog] = useState(false);
+
+ const [showDeployModal, setShowDeployModal] = useState(false);
+ const [showMaintenanceLogModal, setShowMaintenanceLogModal] = useState(false);
+
+ const deviceNum = Number(device?.device_number);
+
+ if (!isLoading && error) {
+ return (
+
+
+
Unable to load device details
+
+ {error?.message || "Device not found."}
+
+
+ );
+ }
+
+ return (
+
+
+
router.back()} Icon={AqArrowLeft}>
+ Back
+
+
+ {isLoading ? (
+
+ ) : !device ? null : (
+
+ {deploymentStatus === "deployed" &&
+ setShowRecallDialog(true)}
+ permission={PERMISSIONS.DEVICE.RECALL}
+ >
+ Recall Device
+
+ }
+
+ {deploymentStatus !== "deployed" &&
+ setShowDeployModal(true)}
+ Icon={AqSignal02}
+ permission={PERMISSIONS.DEVICE.DEPLOY}
+ >
+ Deploy Device
+ }
+
+ setShowMaintenanceLogModal(true)}
+ Icon={AqTool01}
+ permission={PERMISSIONS.DEVICE.MAINTAIN}
+ >
+ Add Maintenance Log
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : !device ? (
+
+ Device not found.
+
+ ) : (
+
+
+ setShowDetailsModal(true)}
+ />
+
+
+
+
+
+
+
+ {Number.isFinite(deviceNum) &&
+
+
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {device && (
+ <>
+
setShowDetailsModal(false)}
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/vertex-template/components/features/devices/device-details-modal.tsx b/src/vertex-template/components/features/devices/device-details-modal.tsx
new file mode 100644
index 0000000000..65b2778560
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-details-modal.tsx
@@ -0,0 +1,680 @@
+import { Switch } from "@/components/ui/switch";
+import { Loader2, Save } from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
+import { useUpdateDeviceLocal, useUpdateDeviceGlobal, useDecryptDeviceKeys } from "@/core/hooks/useDevices";
+import { DEVICE_CATEGORIES } from "@/core/constants/devices";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { Device } from "@/app/types/devices";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { AqEdit01 as AqEdit, AqKey01 } from "@airqo/icons-react";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import logger from "@/lib/logger";
+
+interface DeviceDetailsModalProps {
+ open: boolean;
+ device: Device;
+ onClose: () => void;
+}
+
+// Form validation schema
+const deviceUpdateSchema = z.object({
+ long_name: z.string().min(1, "Device name is required"),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ category: z.string().min(1, "Category is required"),
+ device_number: z.string().optional(),
+ writeKey: z.string().optional(),
+ readKey: z.string().optional(),
+ serial_number: z.string().optional(),
+ latitude: z.string().optional(),
+ longitude: z.string().optional(),
+ visibility: z.boolean().optional(),
+ isPrimaryInLocation: z.boolean().optional(),
+ generation_version: z.string().optional(),
+ generation_count: z.string().optional(),
+ phoneNumber: z.string().optional(),
+ height: z.number().optional(),
+ powerType: z.enum(["solar", "alternator", "mains"]).optional(),
+ claim_status: z.enum(["claimed", "unclaimed"]).optional(),
+ // Read-only fields for display
+ network: z.string().optional(),
+ status: z.enum(["not deployed", "deployed", "recalled", "online", "offline"]).optional(),
+ isActive: z.boolean().optional(),
+ isOnline: z.boolean().optional(),
+ createdAt: z.string().optional(),
+ nextMaintenance: z.string().optional(),
+ api_code: z.string().optional(),
+ authRequired: z.boolean().optional()
+});
+
+type DeviceUpdateFormData = z.infer;
+
+const DeviceDetailsModal: React.FC = ({ open, device, onClose }) => {
+ const [isEditMode, setIsEditMode] = useState(false);
+ const updateLocal = useUpdateDeviceLocal();
+ const updateGlobal = useUpdateDeviceGlobal();
+ const decryptKeys = useDecryptDeviceKeys();
+ const { showBanner } = useBanner();
+
+ const form = useForm({
+ resolver: zodResolver(deviceUpdateSchema),
+ defaultValues: {
+ long_name: device?.long_name || "",
+ name: device?.name || "",
+ description: device?.description || "",
+ category: device?.category || "",
+ device_number: device?.device_number?.toString() || "",
+ writeKey: device?.writeKey || "",
+ readKey: device?.readKey || "",
+ serial_number: device?.serial_number || "",
+ latitude: device?.latitude?.toString() || "",
+ longitude: device?.longitude?.toString() || "",
+ visibility: device?.visibility || false,
+ isPrimaryInLocation: device?.isPrimaryInLocation || false,
+ generation_version: String(device?.generation_version) || "",
+ generation_count: device?.generation_count?.toString() || "",
+ phoneNumber: device?.phoneNumber || "",
+ height: device?.height || undefined,
+ powerType: device?.powerType || undefined,
+ claim_status: device?.claim_status || undefined,
+ api_code: device?.api_code || "",
+ authRequired: device?.authRequired ?? true,
+ },
+ });
+
+ // Reset form when device changes
+ useEffect(() => {
+ if (device) {
+ form.reset({
+ long_name: device.long_name || "",
+ name: device.name || "",
+ description: device.description || "",
+ category: device.category || "",
+ device_number: device.device_number?.toString() || "",
+ writeKey: device.writeKey || "",
+ readKey: device.readKey || "",
+ serial_number: device.serial_number || "",
+ latitude: device.latitude?.toString() || "",
+ longitude: device.longitude?.toString() || "",
+ visibility: device.visibility || false,
+ isPrimaryInLocation: device.isPrimaryInLocation || false,
+ generation_version: String(device.generation_version) || "",
+ generation_count: device.generation_count?.toString() || "",
+ phoneNumber: device.phoneNumber || "",
+ height: device.height || undefined,
+ powerType: device.powerType || undefined,
+ claim_status: device.claim_status || undefined,
+ api_code: device.api_code || "",
+ authRequired: device?.authRequired ?? true,
+ });
+ }
+ }, [device, form]);
+
+ // Reset edit mode when modal is closed
+ useEffect(() => {
+ if (!open) {
+ setIsEditMode(false);
+ form.reset();
+ }
+ }, [open, form]);
+
+ const handleCancel = () => {
+ if (isEditMode) {
+ form.reset();
+ setIsEditMode(false);
+ } else {
+ onClose();
+ }
+ };
+
+ const onSubmitLocal = async (data: DeviceUpdateFormData) => {
+ if (!device?._id) {
+ showBanner({ severity: 'warning', message: "Cannot update device: missing device ID", scoped: true });
+ return;
+ }
+
+ const { dirtyFields } = form.formState;
+ const dirtyData = Object.fromEntries(
+ (Object.keys(dirtyFields) as Array).map((key) => [key, data[key]]),
+ );
+
+ if (Object.keys(dirtyData).length === 0) {
+ setIsEditMode(false);
+ return;
+ }
+
+ const processedData: Record = { ...dirtyData };
+ if (typeof processedData.device_number === "string") {
+ processedData.device_number =
+ processedData.device_number.trim() === "" ? undefined : parseInt(processedData.device_number, 10);
+ }
+ if (typeof processedData.latitude === "string") {
+ processedData.latitude =
+ processedData.latitude.trim() === "" ? undefined : parseFloat(processedData.latitude);
+ }
+ if (typeof processedData.longitude === "string") {
+ processedData.longitude =
+ processedData.longitude.trim() === "" ? undefined : parseFloat(processedData.longitude);
+ }
+ if (typeof processedData.generation_count === "string") {
+ processedData.generation_count =
+ processedData.generation_count.trim() === "" ? undefined : parseInt(processedData.generation_count, 10);
+ }
+
+ updateLocal.mutate(
+ { deviceId: device._id, deviceData: processedData },
+ {
+ onSuccess: () => {
+ showBanner({ severity: 'success', message: 'Device information has been updated locally.', scoped: true });
+ setIsEditMode(false);
+ form.reset(data);
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Update Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ }
+ );
+ };
+
+ const onSubmitGlobal = async (data: DeviceUpdateFormData) => {
+ if (!device?._id) {
+ showBanner({ severity: 'warning', message: "Cannot update device: missing device ID", scoped: true });
+ return;
+ }
+
+ const { dirtyFields } = form.formState;
+ const dirtyData = Object.fromEntries(
+ (Object.keys(dirtyFields) as Array).map((key) => [key, data[key]]),
+ );
+
+ if (Object.keys(dirtyData).length === 0) {
+ setIsEditMode(false);
+ return;
+ }
+
+ const processedData: Record = { ...dirtyData };
+ if (typeof processedData.device_number === "string") {
+ processedData.device_number =
+ processedData.device_number.trim() === "" ? undefined : parseInt(processedData.device_number, 10);
+ }
+ if (typeof processedData.latitude === "string") {
+ processedData.latitude =
+ processedData.latitude.trim() === "" ? undefined : parseFloat(processedData.latitude);
+ }
+ if (typeof processedData.longitude === "string") {
+ processedData.longitude =
+ processedData.longitude.trim() === "" ? undefined : parseFloat(processedData.longitude);
+ }
+ if (typeof processedData.generation_count === "string") {
+ processedData.generation_count =
+ processedData.generation_count.trim() === "" ? undefined : parseInt(processedData.generation_count, 10);
+ }
+
+ updateGlobal.mutate(
+ { deviceId: device._id, deviceData: processedData },
+ {
+ onSuccess: () => {
+ showBanner({ severity: 'success', message: 'Device information has been updated globally.', scoped: true });
+ setIsEditMode(false);
+ form.reset(data);
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Sync Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ }
+ );
+ };
+
+ const onSubmit = async (data: DeviceUpdateFormData) => {
+ const isAirqoManufacturer = device?.network?.toLowerCase() === 'airqo';
+ if (isAirqoManufacturer) {
+ await onSubmitLocal(data);
+ } else {
+ await onSubmitGlobal(data);
+ }
+ };
+
+ const handleCopy = async (valueToCopy: string | number) => {
+ if (valueToCopy !== undefined && valueToCopy !== null) {
+ try {
+ await navigator.clipboard?.writeText(String(valueToCopy))
+ showBanner({ severity: 'success', message: "Decrypted Key Copied", scoped: true });
+ } catch (err) {
+ showBanner({ severity: 'error', message: `Failed to copy key: ${String(err)}`, scoped: true });
+ }
+ }
+ }
+
+ const handleDecryptKey = async (keyType: 'readKey' | 'writeKey') => {
+ const encryptedKey = keyType === 'readKey' ? device.readKey : device.writeKey;
+ const deviceNumber = device.device_number;
+
+ if (!encryptedKey || !deviceNumber) {
+ showBanner({ severity: 'warning', message: `No ${keyType} or device number available for decryption.`, scoped: true });
+ return;
+ }
+
+ try {
+ const response = await decryptKeys.mutateAsync([
+ {
+ encrypted_key: encryptedKey,
+ device_number: Number(deviceNumber),
+ },
+ ]);
+
+ if (response.success && response.decrypted_keys && response.decrypted_keys.length > 0) {
+ handleCopy(response.decrypted_keys[0].decrypted_key);
+ } else {
+ showBanner({ severity: 'error', message: "Decryption failed or no key returned.", scoped: true });
+ }
+ } catch (error) {
+ showBanner({ severity: 'error', message: `Decryption Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ logger.info("Decryption failed");
+ }
+ };
+
+ const isLoading = updateLocal.isPending || updateGlobal.isPending;
+
+ // Edit-only mode view
+ return (
+
+ {isEditMode ? (
+ <>
+
+ Cancel
+
+
+
+ Save
+
+ >
+ ) : (
+ <>
+
+ Close
+
+ setIsEditMode(true)} Icon={AqEdit} permission={PERMISSIONS.DEVICE.UPDATE}>
+ Edit
+
+ >
+ )}
+
+ }
+ >
+
+
+ );
+};
+
+export default DeviceDetailsModal;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/device-history-card.tsx b/src/vertex-template/components/features/devices/device-history-card.tsx
new file mode 100644
index 0000000000..5314a6d5fd
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-history-card.tsx
@@ -0,0 +1,197 @@
+import { Card } from "@/components/ui/card";
+import React, { useEffect, useMemo, useRef } from "react";
+import { useDeviceActivities } from "@/core/hooks/useDevices";
+import DeviceActivityItem from "@/components/features/devices/device-activity-item";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Loader2 } from "lucide-react";
+import type { DevicePreviousSite, DeviceSite } from "@/app/types/devices";
+import type { DeviceActivity } from "@/core/apis/devices";
+
+const shortId = (id: string) =>
+ id.length > 12 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id;
+
+const getSiteName = (site: {
+ _id: string;
+ location_name?: string;
+ name?: string;
+ search_name?: string;
+}) => {
+ return (
+ (typeof site.name === "string" && site.name.trim()) ||
+ (typeof site.location_name === "string" && site.location_name.trim()) ||
+ (typeof site.search_name === "string" && site.search_name.trim()) ||
+ shortId(site._id)
+ );
+};
+
+interface DeviceHistoryCardProps {
+ deviceName: string;
+ previousSites?: Array;
+ currentSite?: DeviceSite[] | { _id: string; name: string };
+}
+
+const DeviceHistoryCard: React.FC = ({
+ deviceName,
+ previousSites,
+ currentSite,
+}) => {
+ const {
+ data,
+ isLoading,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useDeviceActivities(deviceName);
+
+ const observerTarget = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextPage) {
+ fetchNextPage();
+ }
+ },
+ { threshold: 1.0 }
+ );
+
+ const currentTarget = observerTarget.current;
+ if (currentTarget) {
+ observer.observe(currentTarget);
+ }
+
+ return () => {
+ if (currentTarget) {
+ observer.unobserve(currentTarget);
+ }
+ };
+ }, [hasNextPage, fetchNextPage]);
+
+ const activities = data?.pages.flatMap((page) => page.site_activities) || [];
+
+ const siteInfoById = useMemo(() => {
+ const map = new Map();
+
+ if (currentSite) {
+ if (Array.isArray(currentSite)) {
+ currentSite.forEach((s) => {
+ if (!s?._id) return;
+ const label = getSiteName(s);
+ map.set(s._id, { name: s.name, label });
+ });
+ } else if (currentSite?._id) {
+ const label =
+ (typeof currentSite.name === "string" && currentSite.name.trim()) ||
+ shortId(currentSite._id);
+ map.set(currentSite._id, { name: currentSite.name, label });
+ }
+ }
+
+ if (previousSites && previousSites.length > 0) {
+ previousSites.forEach((s) => {
+ if (typeof s === "string") {
+ if (s.trim()) map.set(s, { label: shortId(s) });
+ return;
+ }
+ if (!s?._id) return;
+ const label = getSiteName(s);
+ map.set(s._id, { name: s.name, label });
+ });
+ }
+
+ return map;
+ }, [currentSite, previousSites]);
+
+ const isRecallActivity = (activity: Pick) => {
+ if (activity.activityType === "recall") return true;
+ return (
+ typeof activity.description === "string" &&
+ /\brecalled\b/i.test(activity.description)
+ );
+ };
+
+ const isDeploymentActivity = (activity: Pick) => {
+ if (activity.activityType === "deployment") return true;
+ return (
+ typeof activity.description === "string" &&
+ /\bdeployed\b/i.test(activity.description)
+ );
+ };
+
+ const resolvePreviousSiteLabel = (activity: DeviceActivity, index: number) => {
+ if (!isRecallActivity(activity)) return undefined;
+
+ const directSiteId =
+ typeof activity.site_id === "string" && activity.site_id.trim()
+ ? activity.site_id
+ : undefined;
+
+ let inferredSiteId: string | undefined = directSiteId;
+ if (!inferredSiteId) {
+ for (let j = index + 1; j < activities.length; j += 1) {
+ const next = activities[j];
+ if (isRecallActivity(next)) break;
+ const siteId =
+ typeof next.site_id === "string" && next.site_id.trim()
+ ? next.site_id
+ : undefined;
+ if (siteId && isDeploymentActivity(next)) {
+ inferredSiteId = siteId;
+ break;
+ }
+ }
+ }
+
+ if (!inferredSiteId) return undefined;
+
+ const info = siteInfoById.get(inferredSiteId);
+ return {
+ id: inferredSiteId,
+ name: (typeof info?.name === "string" && info.name.trim()) ? info.name : (info?.label || shortId(inferredSiteId)),
+ };
+ };
+
+ return (
+
+
+
Device Activity
+
+
+
+ {isLoading ? (
+
Loading history...
+ ) : error ? (
+
Failed to load history.
+ ) : activities.length === 0 ? (
+
No recent activity.
+ ) : (
+
+
+ {activities.map((activity, index) => {
+ const previousSite = resolvePreviousSiteLabel(activity, index);
+ return (
+
+ );
+ })}
+ {/* Loading spinner for infinite scroll */}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+
+ )}
+
+
+ );
+};
+
+export default DeviceHistoryCard;
diff --git a/src/vertex-template/components/features/devices/device-list-table.tsx b/src/vertex-template/components/features/devices/device-list-table.tsx
new file mode 100644
index 0000000000..124294d4e8
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-list-table.tsx
@@ -0,0 +1,325 @@
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { AqFilterLines } from "@airqo/icons-react";
+import { Check, Edit, Plus, Trash2, X } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Button } from "@/components/ui/button";
+import { Device } from "@/app/types/devices";
+import { useRouter, useSearchParams } from "next/navigation";
+import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useState, useMemo, useCallback } from "react";
+import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { UnassignCohortDevicesDialog } from "../cohorts/unassign-cohort-devices";
+import { useDevices, DeviceListingOptions } from "@/core/hooks/useDevices";
+import { getColumns, type TableDevice } from "./utils/table-columns";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import BulkEditDevicesModal from "./bulk-edit-device-details-modal";
+
+interface DevicesTableProps {
+ itemsPerPage?: number;
+ onDeviceClick?: (device: Device) => void;
+ multiSelect?: boolean;
+ className?: string;
+}
+
+export default function DevicesTable({
+ itemsPerPage = 25,
+ onDeviceClick,
+ multiSelect = true,
+ className,
+}: DevicesTableProps) {
+ const router = useRouter();
+ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]);
+ const [showAssignDialog, setShowAssignDialog] = useState(false);
+ const [showUnassignDialog, setShowUnassignDialog] = useState(false);
+ const [showBulkEditModal, setShowBulkEditModal] = useState(false);
+ const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]);
+ const { userContext, activeGroup } = useUserContext();
+ const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo";
+
+ const searchParams = useSearchParams();
+ const status = searchParams.get("status");
+
+ const filterOptions = useMemo(() => {
+ if (status === "operational") {
+ return { rawOnlineStatus: true, isOnline: true };
+ }
+ if (status === "transmitting") {
+ return { rawOnlineStatus: true, isOnline: false };
+ }
+ if (status === "not_transmitting") {
+ return { rawOnlineStatus: false, isOnline: false };
+ }
+ if (status === "data_available") {
+ return { rawOnlineStatus: false, isOnline: true };
+ }
+ return {};
+ }, [status]);
+
+ const {
+ pagination,
+ setPagination,
+ searchTerm,
+ setSearchTerm,
+ sorting,
+ setSorting,
+ } = useServerSideTableState({ initialPageSize: itemsPerPage });
+
+ const deviceOptions: DeviceListingOptions = useMemo(
+ () => ({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ filterStatus: status || undefined,
+ ...filterOptions,
+ }),
+ [pagination.pageIndex, pagination.pageSize, searchTerm, sorting, status, filterOptions]
+ );
+
+ const { devices, meta, isFetching, error } = useDevices(deviceOptions);
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ const handleDeviceClick = (item: unknown) => {
+ const device = item as Device;
+ if (onDeviceClick) onDeviceClick(device);
+ else router.push(`/devices/overview/${device._id}`);
+ };
+
+ const handleAssignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowAssignDialog(false);
+ };
+
+ const handleUnassignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowUnassignDialog(false);
+ };
+
+ const handleAddCohortDeviceActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowAssignDialog(true);
+ },
+ []
+ );
+
+ const handleUnassignActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowUnassignDialog(true);
+ },
+ []
+ );
+
+ const handleBulkEditClose = () => {
+ setShowBulkEditModal(false);
+ setBulkEditDeviceIds([]);
+ };
+
+ const devicesWithId: TableDevice[] = useMemo(() => {
+ return devices
+ .filter(
+ (device: Device): device is Device & { _id: string } =>
+ typeof device._id === "string" && device._id.trim() !== ""
+ )
+ .map((device) => ({
+ ...device,
+ site_id:
+ typeof device.site_id === "string" && device.site_id.trim() !== ""
+ ? device.site_id
+ : Array.isArray(device.site)
+ ? device.site?.[0]?._id
+ : device.site?._id,
+ id: device._id,
+ }));
+ }, [devices]);
+
+ const columns = useMemo(() => getColumns(isInternalView), [isInternalView]);
+
+ const additionalExportFields = useMemo(() => [
+ { key: "site_id", title: "Site ID" },
+ { key: "_id", title: "Device ID" },
+ { key: "category", title: "Category" },
+ ], []);
+
+ const actions = useMemo(() => {
+ if (!multiSelect) return [];
+
+ const baseActions: TableAction[] = [
+ {
+ label: "Add to Cohort",
+ value: "assign_cohort",
+ handler: handleAddCohortDeviceActionSubmit,
+ icon: Plus,
+ },
+ {
+ label: "Bulk Edit Devices",
+ value: "bulk_edit",
+ handler: (ids) => {
+ if (!ids.length) return;
+
+ const safeIds = ids.map(String);
+ setBulkEditDeviceIds(safeIds);
+ setShowBulkEditModal(true);
+ },
+ icon: Edit,
+ },
+ {
+ label: "Remove from Cohort",
+ value: "unassign_cohort",
+ handler: handleUnassignActionSubmit,
+ icon: Trash2,
+ }
+ ];
+
+ return baseActions;
+ }, [
+ multiSelect,
+ handleAddCohortDeviceActionSubmit,
+ handleUnassignActionSubmit,
+ ]);
+
+ return (
+
+
setSelectedDeviceObjects(items as TableDevice[])}
+ actions={actions}
+ emptyState={
+ error ? (
+
+
+
Unable to load devices
+
+ {error.message || "An unknown error occurred"}
+
+
+ ) : (
+ "No devices available"
+ )
+ }
+ serverSidePagination
+ pageCount={pageCount}
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ onSearchChange={setSearchTerm}
+ searchTerm={searchTerm}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ searchable
+ customHeaderContent={
+
+
+
+
+
+ Filter
+ {status && (
+
+ 1
+
+ )}
+
+
+
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("status");
+ router.push(`?${params.toString()}`);
+ }} className="justify-between">
+ All Devices
+ {!status && }
+
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("status", "operational");
+ router.push(`?${params.toString()}`);
+ }} className={`justify-between ${status === 'operational' ? 'font-medium bg-accent' : ''}`}>
+ Operational
+ {status === 'operational' && }
+
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("status", "transmitting");
+ router.push(`?${params.toString()}`);
+ }} className={`justify-between ${status === 'transmitting' ? 'font-medium bg-accent' : ''}`}>
+ Transmitting
+ {status === 'transmitting' && }
+
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("status", "not_transmitting");
+ router.push(`?${params.toString()}`);
+ }} className={`justify-between ${status === 'not_transmitting' ? 'font-medium bg-accent' : ''}`}>
+ Not Transmitting
+ {status === 'not_transmitting' && }
+
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("status", "data_available");
+ router.push(`?${params.toString()}`);
+ }} className={`justify-between ${status === 'data_available' ? 'font-medium bg-accent' : ''}`}>
+ Data Available
+ {status === 'data_available' && }
+
+
+
+
+ {status && (
+
{
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("status");
+ router.push(`?${params.toString()}`);
+ }}
+ Icon={X}
+ >
+ Clear filter
+
+ )}
+
+ }
+ />
+
+ {/* Assign to Cohort Dialog */}
+
+
+ {/* Unassign from Cohort Dialog */}
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/devices/device-location-card.tsx b/src/vertex-template/components/features/devices/device-location-card.tsx
new file mode 100644
index 0000000000..dd3a5b282e
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-location-card.tsx
@@ -0,0 +1,94 @@
+import { Card } from "@/components/ui/card";
+import { useRouter, usePathname } from "next/navigation";
+import { Device, DeviceSite } from "@/app/types/devices";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+interface DeviceLocationCardProps {
+ device: Device;
+}
+
+export function DeviceLocationCard({ device }: DeviceLocationCardProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const isAdminMode = pathname.startsWith("/admin");
+
+ const toNumberOrNull = (v: unknown) => {
+ if (v === null || v === undefined || v === '') return null;
+ const n = Number(String(v));
+ return Number.isFinite(n) ? n : null;
+ };
+
+ const lat = toNumberOrNull(device.latitude);
+ const lon = toNumberOrNull(device.longitude);
+
+ const siteId = Array.isArray(device.site)
+ ? device.site[0]?._id
+ : (device.site as { _id: string })?._id;
+
+ const handleEditLocation = () => {
+ if (siteId) {
+ const basePath = isAdminMode ? "/admin/sites" : "/sites";
+ router.push(`${basePath}/${siteId}`);
+ }
+ };
+
+ return (
+
+
+
+ Site Details
+
+
+
+
+ Site
+
+
+ {(() => {
+ if (!device?.site) return "N/A";
+ if (Array.isArray(device.site) && device.site.length > 0) {
+ const site = device.site[0];
+ return site?.name || site?.description || site?.location_name || "N/A";
+ }
+ if (typeof device.site === 'object' && !Array.isArray(device.site)) {
+ return (device.site as DeviceSite).name || (device.site as DeviceSite).description || (device.site as DeviceSite).location_name || "N/A";
+ }
+ return "N/A";
+ })()}
+
+
+
+
+
+
+ Latitude
+
+
+ {lat ? lat.toFixed(6) : "N/A"}
+
+
+
+
+ Longitude
+
+
+ {lon ? lon.toFixed(6) : "N/A"}
+
+
+
+
+
+ {siteId && (
+
+
+ View site details
+
+
+ )}
+
+ );
+}
diff --git a/src/vertex-template/components/features/devices/device-measurements-api-card.tsx b/src/vertex-template/components/features/devices/device-measurements-api-card.tsx
new file mode 100644
index 0000000000..b1507d4c99
--- /dev/null
+++ b/src/vertex-template/components/features/devices/device-measurements-api-card.tsx
@@ -0,0 +1,55 @@
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Copy } from "lucide-react";
+import React from "react";
+import { useClipboard } from "@/core/hooks/useClipboard";
+
+interface DeviceMeasurementsApiCardProps {
+ deviceId: string;
+}
+
+const DeviceMeasurementsApiCard: React.FC = ({ deviceId }) => {
+ const { handleCopy } = useClipboard();
+
+ return (
+
+ Device Measurements API
+ {/* Recent Measurements */}
+
+
Recent Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`}
+
+
handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`)}
+ >
+
+
+
+
+ {/* Historical Measurements */}
+
+
Historical Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`}
+
+
handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`)}
+ >
+
+
+
+
+
+ );
+};
+
+export default DeviceMeasurementsApiCard;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/import-device-modal.tsx b/src/vertex-template/components/features/devices/import-device-modal.tsx
new file mode 100644
index 0000000000..79490c6968
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-device-modal.tsx
@@ -0,0 +1,781 @@
+"use client";
+
+import React, { useState, useEffect, useCallback } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useImportDevice, useBulkImportDevices } from "@/core/hooks/useDevices";
+import type { BulkImportDeviceResponse } from "@/app/types/devices";
+import { DEVICE_CATEGORIES } from "@/core/constants/devices";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import { useAssignDevicesToCohort } from "@/core/hooks/useCohorts";
+import { useAppSelector } from "@/core/redux/hooks";
+import { usePathname } from "next/navigation";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { NetworkRequestDialog } from "../networks/network-request-dialog";
+import Papa from "papaparse";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
+import { AqChevronDown, AqChevronUp } from "@airqo/icons-react";
+import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
+
+import { EXPECTED_FIELDS } from "./import-steps/types";
+import type { ImportDeviceFormData } from "./import-steps/types";
+import dynamic from "next/dynamic";
+
+const StepLoader = () => (
+
+);
+
+const SingleImportForm = dynamic(() => import("./import-steps/SingleImportForm").then(mod => mod.SingleImportForm), { loading: StepLoader });
+const BulkImportForm = dynamic(() => import("./import-steps/BulkImportForm").then(mod => mod.BulkImportForm), { loading: StepLoader });
+const FieldMappingStep = dynamic(() => import("./import-steps/FieldMappingStep").then(mod => mod.FieldMappingStep), { loading: StepLoader });
+const ImportPreviewStep = dynamic(() => import("./import-steps/ImportPreviewStep").then(mod => mod.ImportPreviewStep), { loading: StepLoader });
+const CohortSelectionStep = dynamic(() => import("./import-steps/CohortSelectionStep").then(mod => mod.CohortSelectionStep), { loading: StepLoader });
+const ConfirmationStep = dynamic(() => import("./import-steps/ConfirmationStep").then(mod => mod.ConfirmationStep), { loading: StepLoader });
+const BulkResultsStep = dynamic(() => import("./import-steps/BulkResultsStep").then(mod => mod.BulkResultsStep), { loading: StepLoader });
+const ImportSuccessStep = dynamic(() => import("./import-steps/ImportSuccessStep").then(mod => mod.ImportSuccessStep), { loading: StepLoader });
+const ImportMethodSelectStep = dynamic(() => import("./import-steps/ImportMethodSelectStep").then(mod => mod.ImportMethodSelectStep), { loading: StepLoader });
+
+interface StepCardProps {
+ title: string;
+ stepIndex: number;
+ currentStep: number;
+ onHeaderClick: (stepIndex: number) => void;
+ footer?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+const StepCard: React.FC = ({ title, stepIndex, currentStep, onHeaderClick, children, footer }) => (
+
+
+
+ onHeaderClick(stepIndex)}>
+
+ {title}
+ {currentStep === stepIndex ? : }
+
+
+
+
+ {children}
+ {footer && {footer} }
+
+
+
+);
+
+interface ImportDeviceModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ prefilledNetwork?: string;
+ onSuccess?: (deviceInfo?: { deviceId?: string; deviceName?: string; cohortId?: string; isCohortImport?: boolean }) => void;
+ mode?: 'guided' | 'fast';
+}
+
+const ImportDeviceModal: React.FC = ({
+ open,
+ onOpenChange,
+ prefilledNetwork,
+ onSuccess,
+ mode = 'fast',
+}) => {
+ const isGuidedMode = mode === 'guided';
+
+
+
+ const [currentStep, setCurrentStep] = useState(0);
+ const [importFlow, setImportFlow] = useState<'single' | 'bulk' | null>(null);
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [importedDeviceName, setImportedDeviceName] = useState("");
+
+ const [formData, setFormData] = useState({
+ long_name: "",
+ network: prefilledNetwork || "",
+ category: DEVICE_CATEGORIES[0].value,
+ serial_number: "",
+ description: "",
+ device_number: "",
+ writeKey: "",
+ readKey: "",
+ api_code: "",
+ authRequired: true,
+ tags: [] as string[],
+ });
+
+ const [showMore, setShowMore] = useState(false);
+ const [isRequestDialogOpen, setIsRequestDialogOpen] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ const [bulkFile, setBulkFile] = useState(null);
+ const [bulkResults, setBulkResults] = useState(null);
+ const [parsedData, setParsedData] = useState[]>([]);
+ const [fileHeaders, setFileHeaders] = useState([]);
+ const [fieldMapping, setFieldMapping] = useState>({});
+ const [transformedPreview, setTransformedPreview] = useState[]>([]);
+
+ const [selectedCohortId, setSelectedCohortId] = useState("");
+ const [selectedCohortName, setSelectedCohortName] = useState("");
+
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const importDevice = useImportDevice();
+ const bulkImport = useBulkImportDevices();
+ const assignDevicesToCohort = useAssignDevicesToCohort();
+ const { networks, isLoading: isLoadingNetworks } = useNetworks();
+ const userDetails = useAppSelector((state) => state.user.userDetails);
+
+ const pathname = usePathname();
+ const isAdminPage = pathname?.startsWith('/admin/');
+
+ const resetState = useCallback(() => {
+ setCurrentStep(0);
+ setImportFlow(null);
+ setIsSuccess(false);
+ setImportedDeviceName("");
+ setFormData({
+ long_name: "",
+ network: prefilledNetwork || "",
+ category: DEVICE_CATEGORIES[0].value,
+ serial_number: "",
+ description: "",
+ device_number: "",
+ writeKey: "",
+ readKey: "",
+ api_code: "",
+ authRequired: true,
+ tags: [],
+ });
+ setBulkFile(null);
+ setBulkResults(null);
+ setFileHeaders([]);
+ setParsedData([]);
+ setImportFlow(null);
+ setFieldMapping({});
+ setTransformedPreview([]);
+ setSelectedCohortId("");
+ setSelectedCohortName("");
+ setErrors({});
+ }, [prefilledNetwork]);
+
+ useEffect(() => {
+ let timer: ReturnType;
+ if (!open) {
+ // Delay reset to allow exit animations and prevent reset if open flickers false -> true
+ timer = setTimeout(() => {
+ resetState();
+ }, 300);
+ }
+ return () => {
+ if (timer) clearTimeout(timer);
+ };
+ }, [open, resetState]);
+
+ const handleInputChange = (field: string, value: string | boolean | string[]) => {
+ setFormData((prev: ImportDeviceFormData) => ({
+ ...prev,
+ [field]: value,
+ }));
+ if (errors[field]) {
+ setErrors((prev) => ({ ...prev, [field]: "" }));
+ }
+ };
+
+ const autoMapFields = (headers: string[]) => {
+ const initialMapping: Record = {};
+ const normalizeHeader = (value: string) =>
+ value.toLowerCase().trim().replace(/[^a-z0-9]/g, "");
+ const normalizedHeaders = headers.map(normalizeHeader);
+
+ EXPECTED_FIELDS.forEach((field: { key: string; label: string; required?: boolean }) => {
+ let matchIdx = -1;
+ const key = normalizeHeader(field.key);
+
+ if (key === 'longname') {
+ const aliases = ['longname', 'locationname', 'devicename', 'name'];
+ matchIdx = normalizedHeaders.findIndex(h => aliases.includes(h));
+ } else if (key === 'serialnumber') {
+ const aliases = ['serialnumber', 'locationid', 'serial', 'id'];
+ matchIdx = normalizedHeaders.findIndex(h => aliases.includes(h));
+ } else if (key === 'authrequired') {
+ const aliases = ['authrequired', 'authenticationrequired', 'requiresauth', 'requiredauth', 'auth required'];
+ matchIdx = normalizedHeaders.findIndex(h => aliases.includes(h));
+ } else {
+ matchIdx = normalizedHeaders.findIndex(h => h === key);
+ }
+
+ if (matchIdx !== -1) {
+ initialMapping[field.key] = headers[matchIdx];
+ }
+ });
+
+ setFieldMapping(initialMapping);
+ };
+
+ const handleFileUpload = async (file: File | null) => {
+ setBulkFile(file);
+ setErrors({});
+ if (!file) {
+ setParsedData([]);
+ setFileHeaders([]);
+ setFieldMapping({});
+ setTransformedPreview([]);
+ if (currentStep === 0) {
+ setImportFlow('single');
+ }
+ return;
+ }
+
+ setImportFlow('bulk');
+ const fileName = file.name.toLowerCase();
+
+ if (fileName.endsWith('.csv')) {
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => {
+ const headers = results.meta.fields || [];
+ const rows = results.data as Record[];
+
+ if (rows.length === 0) {
+ setFileHeaders(headers);
+ setParsedData([]);
+ setFieldMapping({});
+ showBanner({ severity: 'error', message: "The uploaded CSV does not contain any devices.", scoped: true });
+ return;
+ }
+
+ setFileHeaders(headers);
+ setParsedData(rows);
+ autoMapFields(headers);
+ },
+ error: (err: { message: string }) => {
+ showBanner({ severity: 'error', message: `Failed to parse CSV: ${err.message}`, scoped: true });
+ }
+ });
+ } else if (fileName.endsWith('.json')) {
+ try {
+ const text = await file.text();
+ const json = JSON.parse(text);
+ const devices = Array.isArray(json) ? json : (json.devices || []);
+ if (!Array.isArray(devices) || devices.length === 0) {
+ showBanner({ severity: 'error', message: 'JSON file must contain an array of devices', scoped: true });
+ return;
+ }
+ const headers = Object.keys(devices[0] || {});
+ setFileHeaders(headers);
+ setParsedData(devices as Record[]);
+ autoMapFields(headers);
+ } catch {
+ showBanner({ severity: 'error', message: 'Invalid JSON file format', scoped: true });
+ }
+ } else {
+ showBanner({ severity: 'error', message: 'Unsupported file type. Please upload a CSV or JSON file.', scoped: true });
+ }
+ };
+
+ const validateSingleForm = () => {
+ const newErrors: Record = {};
+ if (!formData.long_name.trim()) newErrors.long_name = "Device name is required";
+ if (!formData.network) newErrors.network = "Sensor Manufacturer is required";
+ if (!formData.serial_number.trim()) newErrors.serial_number = "Serial number is required";
+ if (!formData.api_code?.trim()) newErrors.api_code = "Device Connection URL is required";
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const validateBulkForm = () => {
+ if (!bulkFile) {
+ showBanner({ severity: 'error', message: "Please upload a file.", scoped: true });
+ return false;
+ }
+ if (!formData.network) {
+ showBanner({ severity: 'error', message: "Please select a Sensor Manufacturer.", scoped: true });
+ return false;
+ }
+ if (!formData.category) {
+ showBanner({ severity: 'error', message: "Please select a Category.", scoped: true });
+ return false;
+ }
+ return true;
+ };
+
+ const transformBulkData = () => {
+ const missingRequired = EXPECTED_FIELDS.filter((f: { key: string; label: string; required?: boolean }) => f.required && !fieldMapping[f.key]);
+ if (missingRequired.length > 0) {
+ showBanner({ severity: 'error', message: `Please map the required fields: ${missingRequired.map((f: { key: string; label: string; required?: boolean }) => f.label).join(', ')}`, scoped: true });
+ return false;
+ }
+
+ const mappedHeaders = Object.values(fieldMapping).filter(Boolean);
+ const duplicateHeaders = mappedHeaders.filter(
+ (header, index) => mappedHeaders.indexOf(header) !== index
+ );
+ if (duplicateHeaders.length > 0) {
+ showBanner({ severity: 'error', message: "Each file column can only be mapped once.", scoped: true });
+ return false;
+ }
+
+ const invalidAuthRows: number[] = [];
+ const transformedDevices = parsedData.map((row, rowIndex) => {
+ const device: Record = {};
+ EXPECTED_FIELDS.forEach((field: { key: string; label: string; required?: boolean }) => {
+ const mappedHeader = fieldMapping[field.key];
+ if (mappedHeader && row[mappedHeader] !== undefined && row[mappedHeader] !== "") {
+ if (field.key === 'authRequired') {
+ const rawValue = String(row[mappedHeader]).trim().toLowerCase();
+ const TRUTHY_VALUES = ['true', '1', 'yes', 'y'];
+ const FALSY_VALUES = ['false', '0', 'no', 'n'];
+
+ if (TRUTHY_VALUES.includes(rawValue)) {
+ device.authRequired = true;
+ } else if (FALSY_VALUES.includes(rawValue)) {
+ device.authRequired = false;
+ } else {
+ invalidAuthRows.push(rowIndex + 1);
+ }
+ } else {
+ device[field.key] = row[mappedHeader];
+ }
+ }
+ });
+
+ device.network = formData.network;
+ device.category = formData.category;
+ if (device.authRequired === undefined) device.authRequired = true;
+ if (formData.tags && formData.tags.length > 0) device.tags = formData.tags;
+
+ return device;
+ });
+
+ if (invalidAuthRows.length > 0) {
+ showBanner({
+ severity: 'error',
+ message: `Invalid Authentication Required value on row(s): ${invalidAuthRows.join(', ')}. Accepted values are: yes, no, true, false, 1, 0, y, n.`,
+ scoped: true,
+ });
+ return false;
+ }
+
+ setTransformedPreview(transformedDevices);
+ return true;
+ };
+
+ const handleNext = () => {
+ if (importFlow === 'bulk') {
+ if (currentStep === 0) {
+ if (validateBulkForm() && parsedData.length > 0) {
+ setCurrentStep(1);
+ }
+ } else if (currentStep === 1) {
+ if (transformBulkData()) {
+ setCurrentStep(2);
+ }
+ } else if (currentStep === 2) {
+ setCurrentStep(3);
+ } else if (currentStep === 3) {
+ if (!isAdminPage && !selectedCohortId) {
+ showBanner({ severity: 'error', message: "Please assign the devices to a cohort.", scoped: true });
+ return;
+ }
+ setCurrentStep(4);
+ }
+ } else if (importFlow === 'single') {
+ if (currentStep === 0) {
+ if (validateSingleForm()) {
+ setCurrentStep(1);
+ }
+ } else if (currentStep === 1) {
+ if (!isAdminPage && !selectedCohortId) {
+ showBanner({ severity: 'error', message: "Please assign the device to a cohort.", scoped: true });
+ return;
+ }
+ setCurrentStep(2);
+ }
+ }
+ };
+
+ const handleBack = () => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ };
+
+ const executeAssignment = (deviceIds: string[], cohortId: string, onAssigned?: () => void) => {
+ assignDevicesToCohort.mutate({
+ cohortId,
+ deviceIds,
+ }, {
+ onSuccess: () => {
+ showBannerWithDelay({
+ severity: 'success',
+ message: `${deviceIds.length} device(s) assigned to cohort successfully`,
+ scoped: false,
+ }, 600);
+ onAssigned?.();
+ },
+ onError: (err) => {
+ showBanner({ severity: 'error', message: `Device imported, but cohort assignment failed: ${getApiErrorMessage(err)}`, scoped: false });
+ }
+ });
+ };
+
+ const handleCompleteSingle = () => {
+ const userId = userDetails?._id;
+ if (!userId) return;
+
+ const deviceDataToSend = { ...formData };
+ (Object.keys(deviceDataToSend) as Array).forEach((key) => {
+ const value = deviceDataToSend[key];
+ if (value === "" || value === undefined || value === null || (Array.isArray(value) && value.length === 0)) {
+ delete deviceDataToSend[key];
+ }
+ });
+
+ importDevice.mutate(
+ {
+ ...deviceDataToSend,
+ user_id: userId,
+ ...(selectedCohortId && { cohort_id: selectedCohortId }),
+ },
+ {
+ onSuccess: (data, variables) => {
+ if (isGuidedMode) {
+ setImportedDeviceName(variables.long_name.trim());
+ setIsSuccess(true);
+ } else {
+ onOpenChange(false);
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${variables.long_name.trim()} has been imported successfully.`,
+ scoped: false
+ }, 300);
+ }
+
+ if (selectedCohortId && data.created_device?._id) {
+ executeAssignment([data.created_device._id], selectedCohortId, () => {
+ onSuccess?.({
+ deviceId: data.created_device._id || '',
+ deviceName: variables.long_name.trim(),
+ cohortId: selectedCohortId,
+ isCohortImport: true,
+ });
+ });
+ } else {
+ onSuccess?.({
+ deviceId: data.created_device._id || '',
+ deviceName: variables.long_name.trim(),
+ cohortId: selectedCohortId,
+ isCohortImport: false,
+ });
+ }
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Import Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ }
+ );
+ };
+
+ const handleCompleteBulk = () => {
+ const userId = userDetails?._id;
+ if (!userId) return;
+
+ const payload = {
+ user_id: userId,
+ ...(selectedCohortId && { cohort_id: selectedCohortId }),
+ ...(formData.network && { network_override: formData.network }),
+ devices: transformedPreview
+ };
+
+ bulkImport.mutate(
+ { type: 'json', payload },
+ {
+ onSuccess: (data) => {
+ const notifyHomeImportSuccess = () => {
+ onSuccess?.({
+ cohortId: selectedCohortId,
+ isCohortImport: !!selectedCohortId,
+ });
+ };
+
+ if (data.failed === 0 && data.imported > 0) {
+ if (isGuidedMode) {
+ setImportedDeviceName(`${data.imported} device(s)`);
+ setIsSuccess(true);
+ } else {
+ onOpenChange(false);
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${data.imported} device(s) imported successfully.`,
+ scoped: false,
+ }, 300);
+ }
+ } else {
+ if (data.imported === 0) {
+ showBanner({
+ severity: 'error',
+ title: 'Import Failed',
+ message: `Failed to import all ${data.failed} device(s).`,
+ scoped: true,
+ });
+ } else {
+ showBanner({
+ severity: 'warning',
+ title: 'Partial Import Success',
+ message: `${data.imported} device(s) imported successfully, but ${data.failed} failed.`,
+ scoped: true,
+ });
+ }
+ if (data.results) {
+ setBulkResults(data);
+ setIsSuccess(true); // show results
+ }
+ }
+
+ if (selectedCohortId && data.results) {
+ const successfulDeviceIds = data.results.filter(r => r.success && r.created_device?._id).map(r => r.created_device!._id);
+ if (successfulDeviceIds.length > 0) {
+ executeAssignment(successfulDeviceIds, selectedCohortId, notifyHomeImportSuccess);
+ } else if (data.imported > 0) {
+ notifyHomeImportSuccess();
+ }
+ } else if (data.imported > 0) {
+ notifyHomeImportSuccess();
+ }
+ },
+ onError: (error) => {
+ showBanner({
+ severity: 'error',
+ title: 'Bulk Import Failed',
+ message: getApiErrorMessage(error),
+ scoped: true,
+ });
+ }
+ }
+ );
+ };
+
+ const handleComplete = () => {
+ if (importFlow === 'bulk') {
+ handleCompleteBulk();
+ } else {
+ handleCompleteSingle();
+ }
+ };
+
+ const handleHeaderClick = (stepIndex: number) => {
+ if (stepIndex > currentStep) {
+ handleNext();
+ } else {
+ setCurrentStep(stepIndex);
+ }
+ };
+
+ // Build the steps based on the mode
+ const getSteps = () => {
+ if (importFlow === 'bulk') {
+ return [
+ {
+ title: "File Upload & Settings",
+ content: (
+
+ ),
+ footer: (
+ <>
+ setImportFlow(null)} className="w-32 mr-3">Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Map Fields",
+ content: (
+
+ ),
+ footer: (
+ <>
+ Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Preview Data",
+ content: ,
+ footer: (
+ <>
+ Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Group Devices",
+ content: (
+ { setSelectedCohortId(id); setSelectedCohortName(name); }}
+ open={open && currentStep === 3}
+ isAdminPage={isAdminPage}
+ preselectedNetwork={formData.network}
+ />
+ ),
+ footer: (
+ <>
+ Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Confirmation",
+ content: (
+
+
You are about to import {transformedPreview.length} devices.
+ {selectedCohortId ? (
+
They will be assigned to the selected cohort: {selectedCohortName} .
+ ) : (
+
They will not be assigned to any cohort.
+ )}
+
+ ),
+ footer: (
+ <>
+ Back
+ Complete
+ >
+ )
+ }
+ ];
+ } else {
+ return [
+ {
+ title: "Device Details",
+ content: (
+
+ ),
+ footer: (
+ <>
+ setImportFlow(null)} className="w-32 mr-3">Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Group Devices",
+ content: (
+ { setSelectedCohortId(id); setSelectedCohortName(name); }}
+ open={open && currentStep === 1}
+ isAdminPage={isAdminPage}
+ preselectedNetwork={formData.network}
+ />
+ ),
+ footer: (
+ <>
+ Back
+ Next
+ >
+ )
+ },
+ {
+ title: "Confirmation",
+ content: ,
+ footer: (
+ <>
+ Back
+ Complete
+ >
+ )
+ }
+ ];
+ }
+ };
+
+ const steps = getSteps();
+
+ return (
+ <>
+ onOpenChange(false)}
+ title="Import External Device"
+ subtitle={isSuccess ? undefined : "Add a device from a different sensor manufacturer"}
+ size="3xl"
+ maxHeight="max-h-[55vh]"
+ preventBackdropClose={true}
+ >
+
+ {isSuccess ? (
+ bulkResults ? (
+
+ ) : (
+
+ )
+ ) : !importFlow ? (
+
+ {
+ setImportFlow(method);
+ setCurrentStep(0);
+ setErrors({});
+ }}
+ />
+
+ ) : (
+
+ {steps.map((step, idx) => (
+
+ {step.content}
+
+ ))}
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default ImportDeviceModal;
diff --git a/src/vertex-template/components/features/devices/import-steps/BulkImportForm.tsx b/src/vertex-template/components/features/devices/import-steps/BulkImportForm.tsx
new file mode 100644
index 0000000000..2ea643f1e7
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/BulkImportForm.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import ReusableFileUpload from "@/components/shared/fileupload/ReusableFileUpload";
+import { Label } from "@/components/ui/label";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { DEVICE_CATEGORIES, DEFAULT_DEVICE_TAGS } from "@/core/constants/devices";
+import type { ImportDeviceFormData } from "./types";
+import type { Network } from "@/core/apis/networks";
+
+interface BulkImportFormProps {
+ bulkFile: File | null;
+ handleFileUpload: (file: File | null) => void;
+ errors: Record;
+ formData: ImportDeviceFormData;
+ handleInputChange: (field: string, value: string | boolean | string[]) => void;
+ networks: Network[];
+ isLoadingNetworks: boolean;
+ isAdminPage: boolean;
+ setIsRequestDialogOpen: (open: boolean) => void;
+}
+
+export const BulkImportForm: React.FC = ({
+ bulkFile,
+ handleFileUpload,
+ errors,
+ formData,
+ handleInputChange,
+ networks,
+ isLoadingNetworks,
+ isAdminPage,
+ setIsRequestDialogOpen,
+}) => {
+ return (
+
+ {errors.general && (
+
+ {errors.general}
+
+ )}
+
+
+
+
+
Apply to all imported devices:
+
+
+
handleInputChange("network", e.target.value)}
+ error={errors.network}
+ required
+ placeholder={isLoadingNetworks ? "Loading Sensor Manufacturer..." : "Select a Sensor Manufacturer"}
+ disabled={isLoadingNetworks}
+ >
+ {networks
+ .filter((network) => network.net_name.toLowerCase() !== 'airqo')
+ .map((network) => (
+
+ {network.net_name}
+
+ ))}
+
+ {!isAdminPage && (
+
+ setIsRequestDialogOpen(true)}
+ variant="text"
+ className="text-xs p-0 px-1 mt-1 h-auto"
+ >
+ Can't find your Sensor Manufacturer?
+
+
+ )}
+
+
+
handleInputChange("category", e.target.value)}
+ error={errors.category}
+ required
+ >
+ {DEVICE_CATEGORIES.map((category) => (
+
+ {category.label}
+
+ ))}
+
+
+
+ Tags (Optional)
+ handleInputChange("tags", tags)}
+ allowCreate={true}
+ />
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/BulkResultsStep.tsx b/src/vertex-template/components/features/devices/import-steps/BulkResultsStep.tsx
new file mode 100644
index 0000000000..754c7cba31
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/BulkResultsStep.tsx
@@ -0,0 +1,45 @@
+import React from "react";
+import type { BulkImportDeviceResponse } from "@/app/types/devices";
+
+interface BulkResultsStepProps {
+ bulkResults: BulkImportDeviceResponse;
+}
+
+export const BulkResultsStep: React.FC = ({ bulkResults }) => {
+ return (
+
+
+
+ {bulkResults.imported} of {bulkResults.total} devices imported. {bulkResults.failed} failed.
+
+
+
+
+
+
+
+ Device Name
+ Serial Number
+ Status
+
+
+
+ {bulkResults.results?.map((result: { success: boolean, long_name?: string, serial_number?: string, error?: string }, idx: number) => (
+
+ {result.long_name || '-'}
+ {result.serial_number || '-'}
+
+ {result.success ? (
+ Success
+ ) : (
+ {result.error || 'Failed'}
+ )}
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/CohortSelectionStep.tsx b/src/vertex-template/components/features/devices/import-steps/CohortSelectionStep.tsx
new file mode 100644
index 0000000000..653bd18d1d
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/CohortSelectionStep.tsx
@@ -0,0 +1,148 @@
+import React, { useState, useEffect } from "react";
+import { ComboBox } from "@/components/ui/combobox";
+import { Label } from "@/components/ui/label";
+import { AqPlus } from "@airqo/icons-react";
+import { useCohorts, useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useAppSelector } from "@/core/redux/hooks";
+import { CreateCohortDialog } from "../../cohorts/create-cohort";
+import type { Cohort } from "@/app/types/cohorts";
+
+interface CohortSelectionStepProps {
+ selectedCohortId: string;
+ onCohortSelect: (id: string, name: string) => void;
+ open: boolean;
+ isAdminPage: boolean;
+ preselectedNetwork?: string;
+}
+
+export const CohortSelectionStep: React.FC = ({
+ selectedCohortId,
+ onCohortSelect,
+ open,
+ isAdminPage,
+ preselectedNetwork,
+}) => {
+ const { isExternalOrg, activeGroup } = useUserContext();
+ const userDetails = useAppSelector((state) => state.user.userDetails);
+ const [cohortSearch, setCohortSearch] = useState("");
+ const [debouncedCohortSearch, setDebouncedCohortSearch] = useState("");
+ const [createCohortModalOpen, setCreateCohortModalOpen] = useState(false);
+ const [optimisticCohort, setOptimisticCohort] = useState<{_id: string, name: string} | null>(null);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedCohortSearch(cohortSearch);
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [cohortSearch]);
+
+ const { data: personalCohortIds, isFetching: isFetchingPersonalCohortIds } = usePersonalUserCohorts(
+ userDetails?._id,
+ { enabled: open && !isAdminPage && !isExternalOrg && !!userDetails?._id }
+ );
+
+ const { cohorts: personalCohorts, isFetching: isFetchingPersonalCohorts } = useCohorts({
+ enabled: open && !isAdminPage && !isExternalOrg && !!personalCohortIds && personalCohortIds.length > 0,
+ search: debouncedCohortSearch,
+ cohort_id: personalCohortIds,
+ limit: 100
+ });
+
+ const { data: groupCohortIds, isFetching: isFetchingCohortIds } = useGroupCohorts(
+ activeGroup?._id,
+ { enabled: open && !isAdminPage && isExternalOrg && !!activeGroup?._id }
+ );
+
+ const { cohorts: groupCohorts, isFetching: isFetchingGroupCohorts } = useCohorts({
+ enabled: open && !isAdminPage && isExternalOrg && !!activeGroup?._id && !!groupCohortIds && groupCohortIds.length > 0,
+ search: debouncedCohortSearch,
+ cohort_id: groupCohortIds,
+ limit: 100
+ });
+
+ const { cohorts: allCohorts, isFetching: isFetchingAllCohorts } = useCohorts({
+ enabled: open && isAdminPage,
+ search: debouncedCohortSearch,
+ limit: 100
+ });
+
+ const cohorts = isAdminPage
+ ? allCohorts
+ : isExternalOrg
+ ? groupCohorts
+ : personalCohorts;
+
+ const isFetchingCohorts = isAdminPage
+ ? isFetchingAllCohorts
+ : isExternalOrg
+ ? (isFetchingGroupCohorts || isFetchingCohortIds)
+ : (isFetchingPersonalCohorts || isFetchingPersonalCohortIds);
+
+ const handleCreateCohortSuccess = (cohortData?: { cohort: { _id: string; name: string } }) => {
+ setCreateCohortModalOpen(false);
+ if (cohortData && cohortData.cohort && cohortData.cohort._id) {
+ setOptimisticCohort(cohortData.cohort);
+ onCohortSelect(cohortData.cohort._id, cohortData.cohort.name);
+ }
+ };
+
+ const handleCreateCohortAction = () => {
+ setCreateCohortModalOpen(true);
+ };
+
+ const handleCohortSelect = (id: string) => {
+ const cohort = cohorts?.find((c: Cohort) => c._id === id);
+ onCohortSelect(id, cohort?.name || "");
+ };
+
+ const baseOptions = cohorts?.map((cohort: Cohort) => ({
+ value: cohort._id,
+ label: cohort.name,
+ })) || [];
+
+ const finalOptions = [...baseOptions];
+ if (optimisticCohort && !finalOptions.find(o => o.value === optimisticCohort._id)) {
+ finalOptions.push({ value: optimisticCohort._id, label: optimisticCohort.name });
+ }
+
+ return (
+
+
+
+ Choose cohort {isAdminPage ? '(Optional)' : * }
+
+
+
+ {isAdminPage
+ ? "You can optionally assign the imported device(s) to a cohort to easily manage them."
+ : "You must assign the imported device(s) to a cohort."}
+
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/ConfirmationStep.tsx b/src/vertex-template/components/features/devices/import-steps/ConfirmationStep.tsx
new file mode 100644
index 0000000000..92b1011b10
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/ConfirmationStep.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import type { ImportDeviceFormData } from "./types";
+
+interface ConfirmationStepProps {
+ formData: ImportDeviceFormData;
+ selectedCohortId: string;
+ selectedCohortName?: string;
+}
+
+export const ConfirmationStep: React.FC = ({
+ formData,
+ selectedCohortId,
+ selectedCohortName,
+}) => {
+ return (
+
+
+
Device Summary
+
+
+
+
Device Name
+ {formData.long_name || '-'}
+
+
+
Serial Number
+ {formData.serial_number || '-'}
+
+
+
Manufacturer
+ {formData.network || '-'}
+
+
+
Category
+ {formData.category || '-'}
+
+
+
Auth Required
+ {formData.authRequired ? 'Yes' : 'No'}
+
+
+
Connection URL
+ {formData.api_code || '-'}
+
+
+
Assigned Cohort
+ {selectedCohortId ? selectedCohortName || 'Loading group...' : 'None (Will not be grouped)'}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/FieldMappingStep.tsx b/src/vertex-template/components/features/devices/import-steps/FieldMappingStep.tsx
new file mode 100644
index 0000000000..206445fb51
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/FieldMappingStep.tsx
@@ -0,0 +1,67 @@
+import React from "react";
+import { EXPECTED_FIELDS } from "./types";
+
+interface FieldMappingStepProps {
+ parsedData: Record[];
+ fileHeaders: string[];
+ fieldMapping: Record;
+ setFieldMapping: React.Dispatch>>;
+ errors: Record;
+}
+
+export const FieldMappingStep: React.FC = ({
+ parsedData,
+ fileHeaders,
+ fieldMapping,
+ setFieldMapping,
+ errors,
+}) => {
+ return (
+
+
+
We found {parsedData.length} devices in your file. Please map the columns from your file to the expected device fields.
+
+
+ {errors.general && (
+
+ {errors.general}
+
+ )}
+
+
+
+
+
+ Expected Field
+ Your File Column
+
+
+
+ {EXPECTED_FIELDS.map((field: { key: string, label: string, required?: boolean }) => (
+
+
+
+ {field.label}
+ {field.required && * }
+
+
+
+ setFieldMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
+ >
+ -- Ignore this field --
+ {fileHeaders.map(header => (
+ {header}
+ ))}
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/ImportMethodSelectStep.tsx b/src/vertex-template/components/features/devices/import-steps/ImportMethodSelectStep.tsx
new file mode 100644
index 0000000000..ecd6cd7225
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/ImportMethodSelectStep.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { Smartphone, FileSpreadsheet } from 'lucide-react';
+
+export type ImportFlowMode = 'single' | 'bulk';
+
+interface ImportMethodSelectStepProps {
+ onSelect: (method: ImportFlowMode) => void;
+}
+
+const METHODS = [
+ {
+ id: 'single' as ImportFlowMode,
+ icon: ,
+ iconBg: 'bg-blue-100 dark:bg-blue-900/40 group-hover:bg-blue-200 dark:group-hover:bg-blue-800',
+ border: 'hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20',
+ title: 'Import Single Device',
+ desc: 'Manually enter credentials for a single device.',
+ },
+ {
+ id: 'bulk' as ImportFlowMode,
+ icon: ,
+ iconBg: 'bg-green-100 dark:bg-green-900/40 group-hover:bg-green-200 dark:group-hover:bg-green-800',
+ border: 'hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20',
+ title: 'Import Multiple Devices',
+ desc: 'Upload a CSV or JSON file to import devices in bulk.',
+ },
+];
+
+export const ImportMethodSelectStep: React.FC = ({ onSelect }) => {
+ return (
+
+
+ {METHODS.map(({ id, icon, iconBg, border, title, desc }) => (
+
onSelect(id)}
+ className={`flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg ${border} transition-colors text-left w-full group`}
+ >
+
+ {icon}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/ImportPreviewStep.tsx b/src/vertex-template/components/features/devices/import-steps/ImportPreviewStep.tsx
new file mode 100644
index 0000000000..1917dc893d
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/ImportPreviewStep.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+
+interface ImportPreviewStepProps {
+ transformedPreview: Record[];
+ errors: Record;
+}
+
+export const ImportPreviewStep: React.FC = ({
+ transformedPreview,
+ errors,
+}) => {
+ return (
+
+
+
Previewing the first {Math.min(5, transformedPreview.length)} of {transformedPreview.length} devices. Please verify the data looks correct before proceeding.
+
+
+ {errors.general && (
+
+ {errors.general}
+
+ )}
+
+
+
+
+
+ Device Name
+ Serial Number
+ Manufacturer
+ Category
+ Auth Required
+ Latitude
+ Longitude
+
+
+
+ {transformedPreview.slice(0, 5).map((device, idx) => (
+
+ {String(device.long_name) || '-'}
+ {String(device.serial_number) || '-'}
+ {String(device.network) || '-'}
+ {String(device.category) || '-'}
+ {device.authRequired === false ? 'No' : 'Yes'}
+ {device.latitude !== undefined ? String(device.latitude) : '-'}
+ {device.longitude !== undefined ? String(device.longitude) : '-'}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/ImportSuccessStep.tsx b/src/vertex-template/components/features/devices/import-steps/ImportSuccessStep.tsx
new file mode 100644
index 0000000000..7fd60ab3e8
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/ImportSuccessStep.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+
+interface ImportSuccessStepProps {
+ deviceName: string;
+}
+
+export const ImportSuccessStep: React.FC = ({ deviceName }) => {
+ return (
+
+
+
+ Device(s) Imported Successfully!
+
+
+ {deviceName} has been added to your workspace.
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/SingleImportForm.tsx b/src/vertex-template/components/features/devices/import-steps/SingleImportForm.tsx
new file mode 100644
index 0000000000..1e8c33686e
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/SingleImportForm.tsx
@@ -0,0 +1,188 @@
+import React from "react";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { MultiSelectCombobox } from "@/components/ui/multi-select";
+import { Label } from "@/components/ui/label";
+import { DEVICE_CATEGORIES, DEFAULT_DEVICE_TAGS } from "@/core/constants/devices";
+import type { ImportDeviceFormData } from "./types";
+import type { Network } from "@/core/apis/networks";
+
+interface SingleImportFormProps {
+ formData: ImportDeviceFormData;
+ errors: Record;
+ handleInputChange: (field: string, value: string | boolean | string[]) => void;
+ showMore: boolean;
+ setShowMore: (show: boolean) => void;
+ networks: Network[];
+ isLoadingNetworks: boolean;
+ isAdminPage: boolean;
+ setIsRequestDialogOpen: (open: boolean) => void;
+}
+
+export const SingleImportForm: React.FC = ({
+ formData,
+ errors,
+ handleInputChange,
+ showMore,
+ setShowMore,
+ networks,
+ isLoadingNetworks,
+ isAdminPage,
+ setIsRequestDialogOpen,
+}) => {
+ return (
+
+
handleInputChange("long_name", e.target.value)}
+ placeholder="Enter device name"
+ error={errors.long_name}
+ required
+ />
+
+
+
+
handleInputChange("network", e.target.value)}
+ error={errors.network}
+ required
+ placeholder={isLoadingNetworks ? "Loading Sensor Manufacturer..." : "Select a Sensor Manufacturer"}
+ disabled={isLoadingNetworks}
+ >
+ {networks
+ .filter((network) => network.net_name.toLowerCase() !== 'airqo')
+ .map((network) => (
+
+ {network.net_name}
+
+ ))}
+
+
+ {!isAdminPage && (
+
+ setIsRequestDialogOpen(true)}
+ variant="text"
+ className="text-xs p-0 px-1 mt-1 h-auto"
+ >
+ Can't find your Sensor Manufacturer?
+
+
+ )}
+
+
+
handleInputChange("category", e.target.value)}
+ error={errors.category}
+ required
+ >
+ {DEVICE_CATEGORIES.map((category) => (
+
+ {category.label}
+
+ ))}
+
+
+
+
+
handleInputChange("authRequired", e.target.value === 'true')}
+ placeholder="Choose whether authentication is required"
+ required
+ >
+ True
+ False
+
+
+
+ Tags (Optional)
+ handleInputChange("tags", tags)}
+ allowCreate={true}
+ />
+
+
+
+
+ handleInputChange("serial_number", e.target.value)}
+ placeholder="Enter serial number"
+ error={errors.serial_number}
+ required
+ />
+
+ handleInputChange("api_code", e.target.value)}
+ placeholder="https://api.mair.com/v1/12345"
+ error={errors.api_code}
+ required
+ />
+
+
+ handleInputChange("description", e.target.value)}
+ placeholder="Enter device description"
+ rows={3}
+ />
+
+ {showMore && (
+
+
handleInputChange("device_number", e.target.value)}
+ placeholder="Enter device number"
+ />
+
+
+ handleInputChange("writeKey", e.target.value)}
+ placeholder="Enter write key"
+ />
+
+ handleInputChange("readKey", e.target.value)}
+ placeholder="Enter read key"
+ />
+
+
+ )}
+
+ setShowMore(!showMore)} className="p-0 h-auto">
+ {showMore ? "Show Less" : "Show More Options"}
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/import-steps/types.ts b/src/vertex-template/components/features/devices/import-steps/types.ts
new file mode 100644
index 0000000000..964e6e8192
--- /dev/null
+++ b/src/vertex-template/components/features/devices/import-steps/types.ts
@@ -0,0 +1,32 @@
+export interface ImportDeviceFormData {
+ long_name: string;
+ network: string;
+ category: string;
+ serial_number: string;
+ description: string;
+ device_number: string;
+ writeKey: string;
+ readKey: string;
+ api_code: string;
+ latitude?: string;
+ longitude?: string;
+ authRequired: boolean;
+ tags: string[];
+}
+
+export interface ExpectedField {
+ key: keyof ImportDeviceFormData;
+ label: string;
+ required: boolean;
+}
+
+export const EXPECTED_FIELDS: ExpectedField[] = [
+ { key: 'long_name', label: 'Device Name', required: true },
+ { key: 'serial_number', label: 'Serial Number', required: true },
+ { key: 'authRequired', label: 'Authentication Required', required: true },
+ { key: 'latitude', label: 'Latitude', required: false },
+ { key: 'longitude', label: 'Longitude', required: false },
+ { key: 'api_code', label: 'Device Connection URL', required: false },
+ { key: 'description', label: 'Description', required: false },
+ { key: 'device_number', label: 'Device Number', required: false },
+];
diff --git a/src/vertex-template/components/features/devices/maintenance-status-card.tsx b/src/vertex-template/components/features/devices/maintenance-status-card.tsx
new file mode 100644
index 0000000000..ef89e80138
--- /dev/null
+++ b/src/vertex-template/components/features/devices/maintenance-status-card.tsx
@@ -0,0 +1,60 @@
+import { Card } from "@/components/ui/card";
+import React from "react";
+import { format, parseISO, isValid, isBefore, startOfToday } from 'date-fns';
+import { AqTool01 } from "@airqo/icons-react";
+import { cn } from "@/lib/utils";
+
+interface MaintenanceStatusCardProps {
+ nextMaintenance: string | undefined;
+}
+
+const MaintenanceStatusCard: React.FC = ({
+ nextMaintenance,
+}) => {
+ const maintenanceDate =
+ typeof nextMaintenance === "string" ? parseISO(nextMaintenance) : null;
+ const hasValidMaintenanceDate =
+ maintenanceDate !== null && isValid(maintenanceDate);
+
+ const isMissed = hasValidMaintenanceDate && isBefore(maintenanceDate, startOfToday());
+
+ return (
+
+
+
Next Maintenance
+ {hasValidMaintenanceDate && (
+
+
+ {isMissed ? "Missed" : "Upcoming"}
+
+ )}
+
+
+
+ {hasValidMaintenanceDate ? (
+
+
+ {format(maintenanceDate, "MMM d, yyyy")}
+
+
+ ) : (
+
+
+
No upcoming maintenance scheduled.
+
+ )}
+
+
+ );
+};
+
+export default MaintenanceStatusCard;
diff --git a/src/vertex-template/components/features/devices/online-status-card.tsx b/src/vertex-template/components/features/devices/online-status-card.tsx
new file mode 100644
index 0000000000..d7f3defa63
--- /dev/null
+++ b/src/vertex-template/components/features/devices/online-status-card.tsx
@@ -0,0 +1,219 @@
+import { Card } from "@/components/ui/card";
+import {
+ Loader2,
+ AlertTriangle,
+ Info,
+} from "lucide-react";
+import { useDeviceDetails } from "@/core/hooks/useDevices";
+import React from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ formatDisplayDate,
+ getDeviceStatus,
+ getStatusExplanation,
+} from "@/core/utils/status";
+
+const statusColorClasses = {
+ green: {
+ bg: "bg-green-600",
+ text: "text-green-700",
+ border: "border-green-600",
+ },
+ blue: {
+ bg: "bg-blue-600",
+ text: "text-blue-700",
+ border: "border-blue-600",
+ },
+ yellow: {
+ bg: "bg-yellow-500",
+ text: "text-yellow-600",
+ border: "border-yellow-500",
+ },
+ gray: {
+ bg: "bg-gray-500",
+ text: "text-gray-600",
+ border: "border-gray-500",
+ },
+ red: {
+ bg: "bg-red-500",
+ text: "text-red-600",
+ border: "border-red-500",
+ },
+ purple: {
+ bg: "bg-purple-600",
+ text: "text-purple-700",
+ border: "border-purple-600",
+ },
+};
+
+interface OnlineStatusCardProps {
+ deviceId: string;
+}
+
+const DateTooltipWrapper: React.FC<{ dateString: string; label: string }> = ({
+ dateString,
+ label,
+}) => {
+ const formattedDate = formatDisplayDate(dateString);
+
+ if (formattedDate.isError) {
+ const isFutureError = formattedDate.errorType === "future";
+
+ return (
+
+
+ {label}:{" "}
+
+
+
+
+ {formattedDate.message}
+
+
+
+ {isFutureError ? (
+ <>
+ Device Level Issue
+
+ The device reported an invalid future date:
+
+
+ {formattedDate.message}
+
+
+ This is likely due to a clock or configuration error.
+
+ >
+ ) : (
+ <>
+ Data Error
+
+ The date received from the device is unreadable or not a
+ valid date.
+
+ >
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {label}: {formattedDate.message}
+
+
+ );
+};
+
+const OnlineStatusCard: React.FC = ({ deviceId }) => {
+ const { data: deviceResponse, isLoading, error } = useDeviceDetails(deviceId);
+ const device = deviceResponse?.data;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+ if (error || !device) {
+ return (
+
+ Error loading online status.
+
+ );
+ }
+
+ const lastActiveCheck = device.lastActive
+ ? formatDisplayDate(device.lastActive)
+ : null;
+
+ const status = getDeviceStatus(
+ device.isOnline,
+ device.rawOnlineStatus,
+ lastActiveCheck
+ );
+
+ const colors = statusColorClasses[status.color];
+ const detailedExplanation = getStatusExplanation(status.label, lastActiveCheck);
+
+ const Icon = status.icon;
+
+ return (
+
+
+ {/* --- Info Icon Tooltip --- */}
+
+
+
+
+
+
+
+ {status.label}
+ {detailedExplanation}
+
+
+ {/* --- End Info Icon --- */}
+
+
+
+
+
+
+
+ {status.label}
+
+
+
+ {status.description}
+
+
+
+
+
+ {device.lastActive ? (
+
+ ) : (
+
+ No data received yet
+
+ )}
+
+
+ {/* --- Hourly Update Info Banner --- */}
+
+
+
+ Statuses are updated on an hourly basis and may not reflect the
+ most recent state of the device.
+
+
+ {/* --- End Info Banner --- */}
+
+
+ );
+};
+
+export default OnlineStatusCard;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/devices/orphaned-devices-alert.tsx b/src/vertex-template/components/features/devices/orphaned-devices-alert.tsx
new file mode 100644
index 0000000000..9dc1ec6623
--- /dev/null
+++ b/src/vertex-template/components/features/devices/orphaned-devices-alert.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { AlertTriangle } from 'lucide-react';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { useAssignDevicesToCohort } from '@/core/hooks/useCohorts';
+import { useQueryClient } from '@tanstack/react-query';
+import { useBanner } from '@/context/banner-context';
+import { useAppSelector } from '@/core/redux/hooks';
+import { useOrphanedDevices } from '@/core/hooks/useDevices';
+
+interface OrphanedDevicesAlertProps {
+ userId: string;
+}
+
+export const OrphanedDevicesAlert: React.FC = ({ userId }) => {
+ const { data, isLoading } = useOrphanedDevices(userId);
+ const { userDetails } = useAppSelector((state) => state.user);
+ const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort();
+ const queryClient = useQueryClient();
+ const { showBanner } = useBanner();
+
+ if (isLoading || !data || !data.devices || data.devices.length === 0) {
+ return null;
+ }
+
+ const handleAssign = () => {
+ const cohortId = userDetails?.cohort_ids?.[0];
+
+ if (!cohortId) {
+ showBanner({ severity: 'error', message: 'An issue occurred while setting up your device. Contact support.', scoped: false });
+ return;
+ }
+
+ const deviceIds = data.devices.map(d => d._id);
+
+ assignDevices(
+ { cohortId, deviceIds },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['orphanedDevices'] });
+ queryClient.invalidateQueries({ queryKey: ['myDevices'] });
+ }
+ }
+ );
+ };
+
+ return (
+
+
+
+ Complete Device Setup
+
+
+
+ We found {data.devices.length} device{data.devices.length > 1 ? 's' : ''} linked to your account that require a final setup step to appear in your dashboard.
+
+
+ Complete Setup
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/devices/qr-scanner.tsx b/src/vertex-template/components/features/devices/qr-scanner.tsx
new file mode 100644
index 0000000000..15c0c76c76
--- /dev/null
+++ b/src/vertex-template/components/features/devices/qr-scanner.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import React, { useEffect, useRef, useState, useCallback } from 'react';
+import { Html5QrcodeScanner, Html5QrcodeScanType } from 'html5-qrcode';
+import { RefreshCw, AlertCircle } from 'lucide-react';
+
+interface QRScannerProps {
+ onScan: (result: string) => void;
+ instructions?: string;
+}
+
+const QRScanner: React.FC = ({
+ onScan,
+ instructions = 'Point your camera at the QR code on your device label',
+}) => {
+ const scannerRef = useRef(null);
+ const containerRef = useRef(null);
+ const isMountedRef = useRef(true);
+ const scannerId = useRef(`qr-reader-${Date.now()}`);
+ const [isInitializing, setIsInitializing] = useState(true);
+ const [error, setError] = useState(null);
+ const [hasPermission, setHasPermission] = useState(null);
+
+ // Stop all camera video tracks in the document
+ const stopAllVideoTracks = useCallback(() => {
+ const videos = document.querySelectorAll('video');
+ videos.forEach(video => {
+ if (video.srcObject) {
+ const stream = video.srcObject as MediaStream;
+ stream.getTracks().forEach(track => track.kind === 'video' && track.stop());
+ video.srcObject = null;
+ }
+ });
+ }, []);
+
+ const cleanupScanner = useCallback(async () => {
+ stopAllVideoTracks();
+ if (scannerRef.current) {
+ try {
+ await scannerRef.current.clear();
+ } catch {
+ // Ignore cleanup errors
+ } finally {
+ scannerRef.current = null;
+ }
+ }
+ }, [stopAllVideoTracks]);
+
+ const handleScanSuccess = useCallback(
+ (decodedText: string) => {
+ if (!isMountedRef.current) return;
+ onScan(decodedText);
+ cleanupScanner();
+ },
+ [onScan, cleanupScanner]
+ );
+
+ const handleScanError = useCallback((errorMessage: string) => {
+ if (!isMountedRef.current) return;
+
+ if (
+ errorMessage.includes('No QR code found') ||
+ errorMessage.includes('NotFound') ||
+ errorMessage.includes('NotFoundException')
+ ) {
+ return;
+ }
+
+ if (
+ errorMessage.includes('NotAllowedError') ||
+ errorMessage.includes('Permission')
+ ) {
+ setHasPermission(false);
+ setError('Camera access denied. Please allow camera access and try again.');
+ return;
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('QR Scanner warning:', errorMessage);
+ }
+ }, []);
+
+ const initializeScanner = useCallback(() => {
+ if (!containerRef.current || scannerRef.current) return;
+
+ setIsInitializing(true);
+ setError(null);
+
+ try {
+ const scanner = new Html5QrcodeScanner(
+ scannerId.current,
+ {
+ qrbox: { width: 250, height: 250 },
+ fps: 10,
+ aspectRatio: 1,
+ showTorchButtonIfSupported: true,
+ showZoomSliderIfSupported: true,
+ supportedScanTypes: [Html5QrcodeScanType.SCAN_TYPE_CAMERA],
+ rememberLastUsedCamera: true,
+ },
+ false
+ );
+
+ scanner.render(handleScanSuccess, handleScanError);
+ scannerRef.current = scanner;
+
+ } catch (err) {
+ console.error('Failed to initialize scanner:', err);
+ setError('Failed to initialize camera. Please refresh and try again.');
+ } finally {
+ setIsInitializing(false);
+ }
+ }, [handleScanSuccess, handleScanError]);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ const timer = setTimeout(() => initializeScanner(), 100);
+
+ return () => {
+ isMountedRef.current = false;
+ clearTimeout(timer);
+ cleanupScanner();
+ };
+ }, [initializeScanner, cleanupScanner]);
+
+ const handleRetry = () => {
+ setError(null);
+ setHasPermission(null);
+ cleanupScanner().then(() => setTimeout(() => initializeScanner(), 100));
+ };
+
+ return (
+
+ {error && (
+
+
+
+
+
{error}
+ {hasPermission === false && (
+
+ Check your browser settings to enable camera access.
+
+ )}
+
+
+
+
+ Try again
+
+
+ )}
+
+ {isInitializing && !error && (
+
+
+
Initializing camera...
+
+ )}
+
+
+
+ {!error && !isInitializing && (
+
{instructions}
+ )}
+
+ );
+};
+
+export { QRScanner };
diff --git a/src/vertex-template/components/features/devices/recall-device-dialog.tsx b/src/vertex-template/components/features/devices/recall-device-dialog.tsx
new file mode 100644
index 0000000000..ad6ca49053
--- /dev/null
+++ b/src/vertex-template/components/features/devices/recall-device-dialog.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { useRecallDevice } from "@/core/hooks/useDevices";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+interface RecallDeviceDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ deviceName: string;
+ deviceDisplayName?: string;
+}
+
+const recallTypes = [
+ { value: "errors", label: "Errors" },
+ { value: "disconnected", label: "Disconnected" },
+];
+
+export default function RecallDeviceDialog({
+ open,
+ onOpenChange,
+ deviceName,
+ deviceDisplayName,
+}: RecallDeviceDialogProps) {
+ const [recallType, setRecallType] = useState("");
+ const recallDevice = useRecallDevice();
+ const { userDetails } = useUserContext();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const handleRecall = async () => {
+ if (!recallType || !userDetails?._id) {
+ return;
+ }
+
+ try {
+ await recallDevice.mutateAsync({
+ deviceName,
+ recallData: {
+ recallType,
+ user_id: userDetails._id,
+ date: new Date().toISOString(),
+ firstName: userDetails.firstName,
+ lastName: userDetails.lastName,
+ email: userDetails.email,
+ userName: userDetails.userName,
+ },
+ });
+ showBannerWithDelay({
+ severity: 'success',
+ title: 'Success',
+ message: `${deviceDisplayName || deviceName} has been recalled successfully.`,
+ scoped: false
+ }, 300);
+ setRecallType("");
+ onOpenChange(false);
+ } catch (error) {
+ showBanner({ severity: 'error', message: `Recall Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ }
+ };
+
+ const isFormValid = recallType && userDetails?._id;
+
+ return (
+ onOpenChange(false)}
+ title="Recall Device"
+ subtitle={`Selected device: ${deviceDisplayName || deviceName}`}
+ size="md"
+ className="h-[55vh]"
+ primaryAction={{
+ label: recallDevice.isPending ? "Recalling..." : "Recall Device",
+ onClick: handleRecall,
+ disabled: !isFormValid || recallDevice.isPending,
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => onOpenChange(false),
+ disabled: recallDevice.isPending,
+ variant: "outline",
+ }}
+ >
+ setRecallType(e.target.value)}
+ placeholder="Select recall type"
+ >
+ {recallTypes.map((type) => {type.label} )}
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/devices/run-device-test-card.tsx b/src/vertex-template/components/features/devices/run-device-test-card.tsx
new file mode 100644
index 0000000000..237e41a950
--- /dev/null
+++ b/src/vertex-template/components/features/devices/run-device-test-card.tsx
@@ -0,0 +1,177 @@
+import { Card } from "@/components/ui/card";
+import { Loader2, RotateCw, AlertTriangle } from "lucide-react"; // Added AlertTriangle
+import { useDeviceStatusFeed } from "@/core/hooks/useDevices";
+import React from "react";
+import { add, format, isAfter, isValid, parseISO } from "date-fns";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface RunDeviceTestCardProps {
+ deviceNumber: number;
+ getElapsedDurationMapper: (dateTimeStr: string) => [number, { [key: string]: number }];
+}
+
+/**
+ * Helper function to check for future dates.
+ */
+const isDateInFuture = (dateString: string | null | undefined): boolean => {
+ if (typeof dateString !== "string" || !dateString.trim()) return false;
+
+ const date = parseISO(dateString);
+ if (!isValid(date)) return false;
+
+ const nowPlus5 = add(new Date(), { minutes: 5 });
+
+ return isAfter(date, nowPlus5);
+};
+
+const RunDeviceTestCard: React.FC = ({ deviceNumber, getElapsedDurationMapper }) => {
+ const statusFeed = useDeviceStatusFeed(deviceNumber);
+
+ return (
+
+
+
Run Device Test
+ statusFeed.refetch()}
+ disabled={statusFeed.isFetching}
+ aria-busy={statusFeed.isFetching}
+ title="Refresh"
+ >
+
+
+
+ {statusFeed.isLoading ? (
+
+
+
+ ) : statusFeed.error ? (
+
+ Could not fetch device status. Please try refreshing.
+
+ ) : statusFeed.data ? (
+ <>
+
+ {(() => {
+ const createdAtRaw = statusFeed.data.created_at || statusFeed.data.timestamp;
+ const createdAt =
+ typeof createdAtRaw === "string" ? createdAtRaw : null;
+ if (!createdAt) return null;
+
+ // --- Future Date Check ---
+ if (isDateInFuture(createdAt)) {
+ const formattedDate = format(parseISO(createdAt), "MMM d yyyy, h:mm a");
+
+ return (
+
+
+
+
+
+
Invalid Date: {formattedDate}
+
+
+
+
+ Device Level Issue
+
+ The device reported an invalid future date:
+
+ {formattedDate}
+
+ This is likely due to a clock or configuration error
+ on the device itself.
+
+
+
+
+ );
+ }
+ // --- END: Future Date Check ---
+
+ const [, elapsedUntyped] = getElapsedDurationMapper(createdAt);
+ const elapsed = elapsedUntyped as Record
;
+ const units = [
+ ["year", "years"],
+ ["month", "months"],
+ ["week", "weeks"],
+ ["day", "days"],
+ ["hour", "hours"],
+ ["minute", "minutes"],
+ ["second", "seconds"],
+ ];
+ const parts = [];
+ for (const [unit, plural] of units) {
+ if (elapsed[unit] > 0) {
+ parts.push(`${elapsed[unit]} ${elapsed[unit] === 1 ? unit : plural}`);
+ }
+ if (parts.length === 2) break;
+ }
+ const relativeString = parts.length ? parts.join(", ") : "just now";
+ const totalHoursOffline =
+ (elapsed.year ?? 0) * 365 * 24 +
+ (elapsed.month ?? 0) * 30 * 24 +
+ (elapsed.week ?? 0) * 7 * 24 +
+ (elapsed.day ?? 0) * 24 +
+ (elapsed.hour ?? 0);
+
+ let colorClass: string;
+ if (totalHoursOffline < 6) {
+ colorClass = "text-green-600";
+ } else if (totalHoursOffline < 24) {
+ colorClass = "text-yellow-600";
+ } else if (totalHoursOffline < 7 * 24) {
+ colorClass = "text-orange-600";
+ } else {
+ colorClass = "text-red-600";
+ }
+ return (
+ <>
+ Device last pushed data{" "}
+
+ {relativeString}
+ {" "}
+ {parts.length ? " ago." : "."}
+ >
+ );
+ })()}
+
+
+ {Object.entries(statusFeed.data)
+ .filter(
+ ([key]) =>
+ !["created_at", "timestamp", "isCache", "satellites", "DeviceType", "undefined"].includes(
+ key
+ )
+ )
+ .map(([key, value]) => {
+ const displayKey = key.length > 20 ? key.slice(0, 20) + "..." : key;
+ return (
+
+
+ {displayKey}
+
+
+ {String(value)}
+
+
+ );
+ })}
+
+ >
+ ) : (
+ No status data available.
+ )}
+
+ );
+};
+
+export default RunDeviceTestCard;
diff --git a/src/vertex-template/components/features/devices/utils/table-columns.tsx b/src/vertex-template/components/features/devices/utils/table-columns.tsx
new file mode 100644
index 0000000000..8c01cb1400
--- /dev/null
+++ b/src/vertex-template/components/features/devices/utils/table-columns.tsx
@@ -0,0 +1,206 @@
+import { Device, DeviceSite } from "@/app/types/devices";
+import {
+ TableColumn,
+ TableItem,
+} from "@/components/shared/table/ReusableTable";
+import React from "react";
+import { format, isValid, parseISO } from "date-fns";
+import {
+ badgeColorClasses,
+ formatDisplayDate,
+ getDeviceStatus,
+} from "@/core/utils/status";
+
+export type TableDevice = TableItem & Device;
+
+export const getColumns = (
+ isInternalView: boolean
+): TableColumn[] => {
+ const columns: TableColumn[] = [
+ {
+ key: "long_name",
+ label: "Device Name",
+ render: (value) => {
+ const name = typeof value === "string" ? value : "";
+ return (
+
+
+ {name}
+
+
+ );
+ },
+ },
+ {
+ key: "isOnline",
+ label: "Device Status",
+ render: (isOnline, item) => {
+ const lastActiveCheck = item.lastActive
+ ? formatDisplayDate(item.lastActive)
+ : null;
+ const status = getDeviceStatus(
+ item.isOnline,
+ item.rawOnlineStatus,
+ lastActiveCheck
+ );
+ const colors = badgeColorClasses[status.color];
+ const Icon = status.icon;
+
+ return (
+
+
+ {status.label}
+
+ );
+ },
+ },
+ {
+ key: "site",
+ label: "Site",
+ render: (siteData) => {
+ const sites = siteData as
+ | DeviceSite[]
+ | { _id: string; name: string }
+ | undefined;
+ if (!sites) {
+ return - ;
+ }
+ const siteName = Array.isArray(sites)
+ ? sites[0]?.name || sites[0]?.location_name
+ : sites?.name;
+ return (
+
+ {siteName || "Not assigned"}
+
+ );
+ },
+ },
+ {
+ key: "status",
+ label: "Deployment Status",
+ render: (status) => {
+ const value = String(status || "").replace(" ", "_");
+ const statusClasses: Record = {
+ deployed: "bg-green-100 text-green-800",
+ recalled: "bg-yellow-100 text-yellow-800",
+ not_deployed: "bg-red-100 text-red-800",
+ };
+ return (
+
+ {String(status || "").replace("_", " ")}
+
+ );
+ },
+ },
+ {
+ key: "createdAt",
+ label: "Created On",
+ render: (value) => {
+ if (typeof value !== "string" || !value) {
+ return "-";
+ }
+
+ const date = parseISO(value);
+
+ return isValid(date)
+ ? format(date, "MMM d yyyy, h:mm a").toUpperCase()
+ : "-";
+ },
+ },
+ {
+ key: "deployment_date",
+ label: "Deployment Date",
+ render: (value) => {
+ if (typeof value !== "string" || !value) {
+ return "-";
+ }
+
+ const date = parseISO(value);
+
+ return isValid(date)
+ ? format(date, "MMM d yyyy, h:mm a").toUpperCase()
+ : "-";
+ },
+ },
+ {
+ key: "latitude",
+ label: "Latitude",
+ render: (value) => {
+ const num = parseFloat(String(value));
+ return !isNaN(num) ? num.toFixed(6) : "-";
+ },
+ },
+ {
+ key: "longitude",
+ label: "Longitude",
+ render: (value) => {
+ const num = parseFloat(String(value));
+ return !isNaN(num) ? num.toFixed(6) : "-";
+ },
+ },
+ {
+ key: "tags",
+ label: "Tags",
+ render: (value) => {
+ const tags = value as string[] | undefined;
+ if (!tags || tags.length === 0) {
+ return - ;
+ }
+ return (
+
+ {tags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ );
+ },
+ },
+ ];
+
+ if (isInternalView) {
+ const groupsColumn: TableColumn = {
+ key: "groups",
+ label: "Organization",
+ render: (value, item) => {
+ const groups = item.groups as string[] | undefined;
+
+ if (!groups || groups.length === 0) {
+ return - ;
+ }
+
+ return (
+
+ {groups.map((groupName) => {
+ const formattedName =
+ typeof groupName === "string"
+ ? groupName.split("_").join(" ")
+ : String(groupName ?? "").replace(/_/g, " ");
+ return (
+
+ {formattedName}
+
+ );
+ })}
+
+ );
+ },
+ };
+ columns.splice(5, 0, groupsColumn);
+ }
+
+ return columns;
+};
diff --git a/src/vertex-template/components/features/feedback/feedback-dialog.ts b/src/vertex-template/components/features/feedback/feedback-dialog.ts
new file mode 100644
index 0000000000..c56636ee6a
--- /dev/null
+++ b/src/vertex-template/components/features/feedback/feedback-dialog.ts
@@ -0,0 +1,9 @@
+export const FEEDBACK_DIALOG_OPEN_EVENT = 'airqo:feedback:open';
+
+export const openFeedbackDialog = (): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.dispatchEvent(new Event(FEEDBACK_DIALOG_OPEN_EVENT));
+};
diff --git a/src/vertex-template/components/features/feedback/feedback-launcher.tsx b/src/vertex-template/components/features/feedback/feedback-launcher.tsx
new file mode 100644
index 0000000000..9dcd164c2e
--- /dev/null
+++ b/src/vertex-template/components/features/feedback/feedback-launcher.tsx
@@ -0,0 +1,945 @@
+'use client';
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { usePathname } from 'next/navigation';
+import { Rating, Star } from '@smastrom/react-rating';
+import '@smastrom/react-rating/style.css';
+import { Flag, Lightbulb, ChevronLeft, Monitor, X, ZoomIn, Pencil, RotateCcw, Check } from 'lucide-react';
+import { AqXClose } from '@airqo/icons-react';
+
+import { useUserContext } from '@/core/hooks/useUserContext';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
+import ReusableSelectInput from '@/components/shared/select/ReusableSelectInput';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { useBanner } from '@/context/banner-context';
+
+import { feedbackService } from '@/core/apis/feedback';
+import { uploadToCloudinary } from '@/core/apis/cloudinary';
+import { FEEDBACK_DIALOG_OPEN_EVENT } from './feedback-dialog';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type MainCategory = 'issue' | 'idea';
+
+interface Rect {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const ISSUE_ACTIONS = [
+ 'Claiming devices',
+ 'Deploying devices',
+ 'Recalling devices',
+ 'Sharing data',
+ 'Device metadata',
+ 'General navigation',
+ 'Other',
+];
+
+const RATING_ITEM_STYLES = {
+ itemShapes: Star,
+ activeFillColor: 'rgb(var(--primary))',
+ inactiveFillColor: 'transparent',
+ activeStrokeColor: 'rgb(var(--primary))',
+ inactiveStrokeColor: 'rgb(var(--primary))',
+ itemStrokeWidth: 2,
+};
+
+const HIGHLIGHT_COLOR = 'rgba(239, 68, 68, 0.25)'; // semi-transparent red fill
+const HIGHLIGHT_STROKE = 'rgba(239, 68, 68, 0.9)'; // solid red border
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const buildFeedbackMetadata = (pathname: string) => {
+ if (typeof window === 'undefined') {
+ return {
+ page: pathname,
+ browser: 'Unknown browser',
+ appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
+ screenResolution: 'Unknown',
+ };
+ }
+
+ const getBrowserLabel = (): string => {
+ const ua = navigator.userAgent;
+ const patterns: Array<{ label: string; pattern: RegExp }> = [
+ { label: 'Edge', pattern: /Edg\/(\d+)/i },
+ { label: 'Chrome', pattern: /Chrome\/(\d+)/i },
+ { label: 'Firefox', pattern: /Firefox\/(\d+)/i },
+ { label: 'Safari', pattern: /Version\/(\d+).+Safari/i },
+ ];
+ for (const { label, pattern } of patterns) {
+ const m = ua.match(pattern);
+ if (m?.[1]) return `${label} ${m[1]}`;
+ }
+ return ua.slice(0, 80);
+ };
+
+ return {
+ page: pathname,
+ browser: getBrowserLabel(),
+ appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
+ screenResolution: `${window.screen.width}x${window.screen.height}`,
+ };
+};
+
+/**
+ * Flatten all drawn rectangles onto a copy of the base image and return a
+ * new data-URL + File representing the annotated screenshot.
+ */
+const flattenAnnotations = (
+ baseDataUrl: string,
+ rects: Rect[],
+ naturalWidth: number,
+ naturalHeight: number,
+): Promise<{ dataUrl: string; file: File }> =>
+ new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = naturalWidth;
+ canvas.height = naturalHeight;
+ const ctx = canvas.getContext('2d')!;
+
+ ctx.drawImage(img, 0, 0);
+
+ for (const r of rects) {
+ ctx.fillStyle = HIGHLIGHT_COLOR;
+ ctx.fillRect(r.x, r.y, r.w, r.h);
+ ctx.strokeStyle = HIGHLIGHT_STROKE;
+ ctx.lineWidth = Math.max(2, naturalWidth / 500);
+ ctx.strokeRect(r.x, r.y, r.w, r.h);
+ }
+
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve({ dataUrl, file: new File([blob], 'screenshot.jpg', { type: 'image/jpeg' }) });
+ } else {
+ reject(new Error('toBlob returned null'));
+ }
+ },
+ 'image/jpeg',
+ 0.9,
+ );
+ };
+ img.onerror = reject;
+ img.src = baseDataUrl;
+ });
+
+// ---------------------------------------------------------------------------
+// ScreenshotAnnotator
+// ---------------------------------------------------------------------------
+// A self-contained canvas editor that lets the user draw highlight rectangles
+// on the captured screenshot before confirming.
+
+interface ScreenshotAnnotatorProps {
+ /** Raw (un-annotated) screenshot data URL */
+ baseDataUrl: string;
+ onConfirm: (rects: Rect[]) => void;
+ onCancel: () => void;
+}
+
+const ScreenshotAnnotator: React.FC = ({
+ baseDataUrl,
+ onConfirm,
+ onCancel,
+}) => {
+ const canvasRef = useRef(null);
+ const imgRef = useRef(null);
+ const [rects, setRects] = useState([]);
+ const [drawing, setDrawing] = useState(false);
+ const [current, setCurrent] = useState(null);
+ const startRef = useRef<{ x: number; y: number } | null>(null);
+ const [imgLoaded, setImgLoaded] = useState(false);
+
+ // Load the base image once.
+ useEffect(() => {
+ const img = new Image();
+ img.onload = () => {
+ imgRef.current = img;
+ setImgLoaded(true);
+ };
+ img.src = baseDataUrl;
+ }, [baseDataUrl]);
+
+ // Redraw whenever rects or the current in-progress rect changes.
+ const redraw = useCallback(() => {
+ const canvas = canvasRef.current;
+ const img = imgRef.current;
+ if (!canvas || !img) return;
+
+ const ctx = canvas.getContext('2d')!;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+
+ const allRects = current ? [...rects, current] : rects;
+ for (const r of allRects) {
+ ctx.fillStyle = HIGHLIGHT_COLOR;
+ ctx.fillRect(r.x, r.y, r.w, r.h);
+ ctx.strokeStyle = HIGHLIGHT_STROKE;
+ ctx.lineWidth = 2;
+ ctx.strokeRect(r.x, r.y, r.w, r.h);
+ }
+ }, [rects, current]);
+
+ useEffect(() => {
+ if (imgLoaded) redraw();
+ }, [imgLoaded, redraw]);
+
+ // Fit canvas to the image's natural dimensions on load, then scale via CSS.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const img = imgRef.current;
+ if (!canvas || !img || !imgLoaded) return;
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ redraw();
+ }, [imgLoaded, redraw]);
+
+ const getPos = (e: React.MouseEvent): { x: number; y: number } => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ const scaleX = canvas.width / rect.width;
+ const scaleY = canvas.height / rect.height;
+ return {
+ x: (e.clientX - rect.left) * scaleX,
+ y: (e.clientY - rect.top) * scaleY,
+ };
+ };
+
+ const onMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const pos = getPos(e);
+ startRef.current = pos;
+ setDrawing(true);
+ setCurrent({ x: pos.x, y: pos.y, w: 0, h: 0 });
+ };
+
+ const onMouseMove = (e: React.MouseEvent) => {
+ if (!drawing || !startRef.current) return;
+ const pos = getPos(e);
+ const x = Math.min(pos.x, startRef.current.x);
+ const y = Math.min(pos.y, startRef.current.y);
+ const w = Math.abs(pos.x - startRef.current.x);
+ const h = Math.abs(pos.y - startRef.current.y);
+ setCurrent({ x, y, w, h });
+ redraw();
+ };
+
+ const onMouseUp = (e: React.MouseEvent) => {
+ if (!drawing || !startRef.current) return;
+ const pos = getPos(e);
+ const x = Math.min(pos.x, startRef.current.x);
+ const y = Math.min(pos.y, startRef.current.y);
+ const w = Math.abs(pos.x - startRef.current.x);
+ const h = Math.abs(pos.y - startRef.current.y);
+ // Ignore tiny accidental clicks (< 5px in either dimension).
+ if (w > 5 && h > 5) {
+ setRects((prev) => [...prev, { x, y, w, h }]);
+ }
+ setCurrent(null);
+ setDrawing(false);
+ startRef.current = null;
+ };
+
+ const handleUndo = () => setRects((prev) => prev.slice(0, -1));
+
+ const handleConfirm = () => {
+ const img = imgRef.current;
+ if (!img) return;
+ onConfirm(
+ // Scale rects back to natural image coordinates (canvas already uses natural coords)
+ rects,
+ );
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+ Draw rectangles to highlight areas of interest
+
+
+ — click and drag on the image
+
+
+
+
+
+
+ Undo
+
+
+
+
+ Cancel
+
+
+
+
+ Done
+
+
+
+
+ {/* Canvas area */}
+
+ {!imgLoaded ? (
+
Loading screenshot…
+ ) : (
+
+ )}
+
+
+ {/* Hint footer */}
+
+ {rects.length === 0
+ ? 'No highlights yet — drag on the image to add one'
+ : `${rects.length} highlight${rects.length > 1 ? 's' : ''} added`}
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// FeedbackLauncher
+// ---------------------------------------------------------------------------
+
+export const FeedbackLauncher: React.FC = () => {
+ const pathname = usePathname();
+ const { userDetails } = useUserContext();
+ const { showBanner } = useBanner();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isUploadingScreenshot, setIsUploadingScreenshot] = useState(false);
+ const [isCapturing, setIsCapturing] = useState(false);
+
+ const [mainCategory, setMainCategory] = useState(null);
+ const [issueAction, setIssueAction] = useState('');
+ const [message, setMessage] = useState('');
+ const [rating, setRating] = useState(3);
+
+ // Base (raw) screenshot — used as the source for the annotator.
+ const [rawDataUrl, setRawDataUrl] = useState(null);
+ // Natural dimensions of the raw screenshot (needed for flatten).
+ const [rawDimensions, setRawDimensions] = useState<{ w: number; h: number } | null>(null);
+
+ // Final (annotated) screenshot shown in the form.
+ const [screenshotDataUrl, setScreenshotDataUrl] = useState(null);
+ const [screenshotFile, setScreenshotFile] = useState(null);
+
+ // UI state
+ const [annotatorOpen, setAnnotatorOpen] = useState(false);
+ const [previewOpen, setPreviewOpen] = useState(false);
+
+ const defaultMetadata = useMemo(() => buildFeedbackMetadata(pathname || ''), [pathname]);
+
+ // -------------------------------------------------------------------------
+ // Event: open feedback dialog
+ // -------------------------------------------------------------------------
+ useEffect(() => {
+ const handler = () => setIsOpen(true);
+ window.addEventListener(FEEDBACK_DIALOG_OPEN_EVENT, handler);
+ return () => window.removeEventListener(FEEDBACK_DIALOG_OPEN_EVENT, handler);
+ }, []);
+
+ // -------------------------------------------------------------------------
+ // Reset on close
+ // -------------------------------------------------------------------------
+ useEffect(() => {
+ if (!isOpen) {
+ setMainCategory(null);
+ setIssueAction('');
+ setMessage('');
+ setRating(3);
+ setRawDataUrl(null);
+ setRawDimensions(null);
+ setScreenshotDataUrl(null);
+ setScreenshotFile(null);
+ setAnnotatorOpen(false);
+ setPreviewOpen(false);
+ }
+ }, [isOpen]);
+
+ // Close screenshot preview on Escape key press
+ useEffect(() => {
+ if (!previewOpen) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setPreviewOpen(false);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [previewOpen]);
+
+ // -------------------------------------------------------------------------
+ // Capture — uses getDisplayMedia for pixel-perfect output
+ // -------------------------------------------------------------------------
+ const captureScreenshot = async () => {
+ // 1. Hide the feedback dialog from the DOM so it won't appear in the capture.
+ setIsCapturing(true);
+
+ // 2. Give React one tick to flush the `hidden` class onto the wrapper div
+ // before the browser's share-picker is invoked.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ try {
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: { displaySurface: 'browser' },
+ audio: false,
+ } as DisplayMediaStreamOptions);
+
+ const track = stream.getVideoTracks()[0];
+
+ // 3. Wait for the track to reach "live" state — the picker has resolved
+ // and frames are actually flowing.
+ await new Promise((resolve) => {
+ if (track.readyState === 'live') {
+ resolve();
+ } else {
+ const timeout = setTimeout(() => resolve(), 3000);
+ track.addEventListener(
+ 'unmute',
+ () => {
+ clearTimeout(timeout);
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // 4. Extra delay to let the picker overlay finish animating out of the
+ // composited frame buffer before we grab.
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ let dataUrl = '';
+ let captureWidth = 0;
+ let captureHeight = 0;
+
+ if ('ImageCapture' in window) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const imageCapture = new (window as any).ImageCapture(track);
+ const bitmap = await imageCapture.grabFrame();
+ track.stop();
+
+ const canvas = document.createElement('canvas');
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ canvas.getContext('2d')!.drawImage(bitmap, 0, 0);
+
+ dataUrl = canvas.toDataURL('image/jpeg', 0.9);
+ captureWidth = bitmap.width;
+ captureHeight = bitmap.height;
+ } catch (captureErr) {
+ console.warn('ImageCapture failed, falling back to video element:', captureErr);
+ }
+ }
+
+ // Fallback: Use element to extract the frame if ImageCapture is missing or failed
+ if (!dataUrl) {
+ const video = document.createElement('video');
+ video.srcObject = stream;
+ video.autoplay = true;
+ video.playsInline = true;
+ video.muted = true;
+
+ await new Promise((resolve) => {
+ video.onloadedmetadata = () => {
+ video.play().then(() => resolve()).catch(() => resolve());
+ };
+ // Safety timeout in case metadata loading stalls
+ setTimeout(resolve, 1000);
+ });
+
+ const canvas = document.createElement('canvas');
+ canvas.width = video.videoWidth || window.innerWidth;
+ canvas.height = video.videoHeight || window.innerHeight;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ }
+ track.stop();
+ dataUrl = canvas.toDataURL('image/jpeg', 0.9);
+ captureWidth = canvas.width;
+ captureHeight = canvas.height;
+ }
+
+ // Store raw screenshot + dimensions, then open the annotator.
+ setRawDataUrl(dataUrl);
+ setRawDimensions({ w: captureWidth, h: captureHeight });
+ setAnnotatorOpen(true);
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name === 'NotAllowedError') return; // user cancelled
+ showBanner({ severity: 'error', message: 'Failed to capture screenshot', scoped: true });
+ } finally {
+ setIsCapturing(false);
+ }
+ };
+
+ // -------------------------------------------------------------------------
+ // Annotator callbacks
+ // -------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks "Done" in the annotator.
+ * Flattens the rectangles onto the image and stores the result.
+ */
+ const handleAnnotatorConfirm = async (rects: Rect[]) => {
+ setAnnotatorOpen(false);
+
+ if (!rawDataUrl || !rawDimensions) return;
+
+ try {
+ // If no highlights were drawn, use the raw screenshot as-is.
+ if (rects.length === 0) {
+ const img = new Image();
+ await new Promise((res, rej) => {
+ img.onload = () => res();
+ img.onerror = rej;
+ img.src = rawDataUrl;
+ });
+
+ // Convert to File via an intermediate canvas.
+ const canvas = document.createElement('canvas');
+ canvas.width = rawDimensions.w;
+ canvas.height = rawDimensions.h;
+ canvas.getContext('2d')!.drawImage(img, 0, 0);
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
+
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ setScreenshotDataUrl(dataUrl);
+ setScreenshotFile(new File([blob], 'screenshot.jpg', { type: 'image/jpeg' }));
+ showBanner({ severity: 'success', message: 'Screenshot captured successfully', scoped: true });
+ }
+ },
+ 'image/jpeg',
+ 0.9,
+ );
+ return;
+ }
+
+ const { dataUrl, file } = await flattenAnnotations(
+ rawDataUrl,
+ rects,
+ rawDimensions.w,
+ rawDimensions.h,
+ );
+
+ setScreenshotDataUrl(dataUrl);
+ setScreenshotFile(file);
+ } catch {
+ showBanner({ severity: 'error', message: 'Failed to process screenshot annotations', scoped: true });
+ }
+ };
+
+ /** User cancelled the annotator — discard the raw capture. */
+ const handleAnnotatorCancel = () => {
+ setAnnotatorOpen(false);
+ setRawDataUrl(null);
+ setRawDimensions(null);
+ };
+
+ // -------------------------------------------------------------------------
+ // Remove / re-capture
+ // -------------------------------------------------------------------------
+ const handleRemoveScreenshot = () => {
+ setScreenshotFile(null);
+ setScreenshotDataUrl(null);
+ setRawDataUrl(null);
+ setRawDimensions(null);
+ };
+
+ // -------------------------------------------------------------------------
+ // Submit
+ // -------------------------------------------------------------------------
+ const handleSubmit = async () => {
+ const trimmedMessage = message.trim();
+ const userEmail = userDetails?.email || userDetails?.userName || '';
+
+ if (!trimmedMessage) {
+ showBanner({ severity: 'error', message: 'Please provide a description.', scoped: true });
+ return;
+ }
+ if (mainCategory === 'issue' && !issueAction) {
+ showBanner({ severity: 'error', message: 'Please select what you were trying to do.', scoped: true });
+ return;
+ }
+ if (!userEmail) {
+ showBanner({ severity: 'error', message: 'Unable to identify your account. Please try again in a moment.', scoped: true });
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ let screenshot_url: string | undefined;
+
+ if (screenshotFile) {
+ setIsUploadingScreenshot(true);
+ try {
+ const uploaded = await uploadToCloudinary(screenshotFile, {
+ folder: 'feedback',
+ tags: ['vertex', 'feedback'],
+ });
+ screenshot_url = uploaded.secure_url;
+ } catch {
+ setTimeout(() => {
+ showBanner({
+ severity: 'warning',
+ title: 'Screenshot upload failed',
+ message: 'We could not attach the screenshot to your feedback. Your feedback will still be submitted.',
+ scoped: true,
+ });
+ }, 300);
+ } finally {
+ setIsUploadingScreenshot(false);
+ }
+ }
+
+ const subject =
+ mainCategory === 'issue' ? `Issue while trying to: ${issueAction}` : 'Product Suggestion';
+
+ await feedbackService.submitFeedback({
+ email: userEmail,
+ subject,
+ message: trimmedMessage,
+ rating,
+ category: mainCategory === 'issue' ? 'bug' : 'feature_request',
+ platform: 'web',
+ app: 'vertex',
+ screenshot_url,
+ metadata: defaultMetadata,
+ });
+
+ setIsOpen(false);
+
+ setTimeout(() => {
+ showBanner({ severity: 'success', message: 'Thank you! Your feedback has been received.', scoped: false });
+ }, 300);
+
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ message:
+ error instanceof Error
+ ? getApiErrorMessage(error)
+ : 'Oops! An error occurred while submitting your feedback. Please try again later.',
+ scoped: true,
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // -------------------------------------------------------------------------
+ // Render helpers
+ // -------------------------------------------------------------------------
+
+ const renderStep1 = () => (
+
+
setMainCategory('issue')}
+ className="flex items-center gap-4 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
+ >
+
+
+
+ Report an issue
+
+
+
setMainCategory('idea')}
+ className="flex items-center gap-4 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
+ >
+
+
+
+ Suggest an idea
+
+
+ );
+
+ const renderStep2 = () => (
+
+ {mainCategory === 'issue' && (
+
setIssueAction(e.target.value)}
+ required
+ placeholder="Choose an option"
+ >
+ {ISSUE_ACTIONS.map((action) => (
+
+ {action}
+
+ ))}
+
+ )}
+
+
setMessage(e.target.value)}
+ placeholder={
+ mainCategory === 'issue'
+ ? 'Explain what happened and what you expected'
+ : 'Tell us how we can improve our product'
+ }
+ rows={5}
+ required
+ description="Please don't include any sensitive information"
+ />
+
+ {/* Screenshot section */}
+
+
+ A screenshot will help us better understand {mainCategory === 'issue' ? 'the issue' : 'your idea'}.
+
+
+ {screenshotFile && screenshotDataUrl ? (
+
+ {/* Thumbnail — click to open full preview */}
+
setPreviewOpen(true)}
+ className="group relative w-full overflow-hidden rounded-lg border border-gray-300 dark:border-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ aria-label="Preview screenshot"
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+ {/* File info + actions */}
+
+
+
+
+ {screenshotFile.name}
+
+
+ ({(screenshotFile.size / 1024).toFixed(0)} KB)
+
+
+
+ Remove
+
+
+
+ ) : (
+
+
+ {isCapturing ? 'Capturing…' : 'Capture screenshot'}
+
+ )}
+
+
+ {/* Rating */}
+
+
+ Experience rating *
+
+
+
+
+
+
+ );
+
+ const dialogTitle = mainCategory
+ ? mainCategory === 'issue'
+ ? 'Report an issue'
+ : 'Suggest an idea'
+ : 'Send feedback to AirQo';
+
+ // -------------------------------------------------------------------------
+ // Render
+ // -------------------------------------------------------------------------
+ return (
+ <>
+ {/* Wrapper is hidden while capturing so the modal is excluded from the frame. */}
+
+ {/* Main feedback dialog */}
+
setIsOpen(false)}
+ title={dialogTitle}
+ size="xl"
+ customHeader={
+ mainCategory ? (
+
+
+ setMainCategory(null)}
+ padding="p-0"
+ className="h-8 w-8"
+ Icon={ChevronLeft}
+ aria-label="Go back"
+ />
+
+ {dialogTitle}
+
+
+
setIsOpen(false)}
+ padding="p-0"
+ className="h-8 w-8"
+ Icon={AqXClose}
+ aria-label="Close dialog"
+ />
+
+ ) : undefined
+ }
+ showFooter={!!mainCategory}
+ primaryAction={
+ mainCategory
+ ? {
+ label: isUploadingScreenshot ? 'Uploading…' : isSubmitting ? 'Sending…' : 'Send',
+ onClick: handleSubmit,
+ disabled: isSubmitting || isUploadingScreenshot,
+ }
+ : undefined
+ }
+ secondaryAction={
+ mainCategory
+ ? {
+ label: 'Cancel',
+ onClick: () => setIsOpen(false),
+ disabled: isSubmitting || isUploadingScreenshot,
+ variant: 'outline',
+ }
+ : undefined
+ }
+ >
+ {!mainCategory ? renderStep1() : renderStep2()}
+
+
+
+ {/* Screenshot annotator — full-screen, rendered outside the dialog */}
+ {annotatorOpen && rawDataUrl && (
+
+ )}
+
+ {/* Full-size preview modal */}
+ {previewOpen && screenshotDataUrl && (
+ setPreviewOpen(false)}
+ role="dialog"
+ aria-modal="true"
+ aria-label="Screenshot preview"
+ >
+
e.stopPropagation()}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
setPreviewOpen(false)}
+ className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80 transition-colors"
+ aria-label="Close preview"
+ >
+
+
+
+
+ )}
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/vertex-template/components/features/feedback/login-feedback-toast.tsx b/src/vertex-template/components/features/feedback/login-feedback-toast.tsx
new file mode 100644
index 0000000000..f4023c1ab7
--- /dev/null
+++ b/src/vertex-template/components/features/feedback/login-feedback-toast.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import { feedbackService } from "@/core/apis/feedback";
+import {
+ getLoginFeedbackRecord,
+ setLoginFeedbackRecord,
+} from "@/core/utils/userPreferences";
+import { ReusableSatisfactionFeedbackToast } from "./reusable-satisfaction-feedback-toast";
+
+const LOGIN_NEGATIVE_REASONS = [
+ "It took too long to load",
+ "I had trouble with my password",
+ "I got an error message",
+ "The page froze or crashed",
+ "Other",
+];
+
+interface LoginFeedbackToastProps {
+ userId: string;
+ email: string;
+}
+
+const LoginFeedbackToast: React.FC = ({
+ userId,
+ email,
+}) => {
+ const [shouldAskForFeedback, setShouldAskForFeedback] = useState(false);
+ const loginDurationRef = useRef(0);
+
+ useEffect(() => {
+ if (!userId) {
+ setShouldAskForFeedback(false);
+ return;
+ }
+
+ const record = getLoginFeedbackRecord(userId);
+ if (record && Date.now() < record.expiresAt) {
+ setShouldAskForFeedback(false);
+ return;
+ }
+
+ const loginStartTs = Number(
+ sessionStorage.getItem("vertex_login_start_ts") || 0
+ );
+ loginDurationRef.current =
+ loginStartTs > 0 ? Date.now() - loginStartTs : 0;
+ setShouldAskForFeedback(true);
+ }, [userId]);
+
+ const submitLoginFeedback = async (rating: number, description?: string) => {
+ await feedbackService.submitLoginFeedback({
+ email,
+ rating,
+ description,
+ loginDurationMs: loginDurationRef.current,
+ submittedAt: Date.now(),
+ });
+ setLoginFeedbackRecord(userId);
+ };
+
+ return (
+ submitLoginFeedback(5)}
+ onNegativeSubmit={(description) => submitLoginFeedback(1, description)}
+ onDismiss={() => setShouldAskForFeedback(false)}
+ />
+ );
+};
+
+export default LoginFeedbackToast;
diff --git a/src/vertex-template/components/features/feedback/page-satisfaction-banner.tsx b/src/vertex-template/components/features/feedback/page-satisfaction-banner.tsx
new file mode 100644
index 0000000000..edcb670476
--- /dev/null
+++ b/src/vertex-template/components/features/feedback/page-satisfaction-banner.tsx
@@ -0,0 +1,292 @@
+'use client';
+
+import React, { useState } from 'react';
+import { ThumbsUp, ThumbsDown, MessageSquare } from 'lucide-react';
+import { openFeedbackDialog } from './feedback-dialog';
+import { feedbackService } from '@/core/apis/feedback';
+import { useUserContext } from '@/core/hooks/useUserContext';
+import { useBanner } from '@/context/banner-context';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+import { usePathname } from 'next/navigation';
+import { usePageTitleContext } from '@/context/page-title-context';
+import Card from '@/components/shared/card/CardWrapper';
+
+const NEGATIVE_REASONS = [
+ 'Confusing',
+ 'Not helpful',
+ 'Missing features',
+ 'Missing or limited data',
+ 'I encountered technical issues',
+ 'Other',
+];
+
+const POSITIVE_REASONS = [
+ 'Informative',
+ 'Actionable',
+ 'Makes my job easier',
+ 'Ease of use',
+ 'Other',
+];
+
+interface FeedbackModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (
+ rating: number,
+ reason: string,
+ message: string
+ ) => Promise;
+ isSubmitting: boolean;
+ type: 'positive' | 'negative';
+}
+
+const FeedbackModal: React.FC = ({
+ isOpen,
+ onClose,
+ onSubmit,
+ isSubmitting,
+ type,
+}) => {
+ const [reason, setReason] = useState('');
+ const [message, setMessage] = useState('');
+ const { showBanner } = useBanner();
+
+ const reasons = type === 'positive' ? POSITIVE_REASONS : NEGATIVE_REASONS;
+
+ const handleSubmit = async () => {
+ if (!reason) {
+ return;
+ }
+ const submitted = await onSubmit(
+ type === 'positive' ? 5 : 1,
+ reason,
+ message
+ );
+ if (!submitted) return;
+ setReason('');
+ setMessage('');
+ onClose();
+ setTimeout(() => {
+ showBanner({
+ severity: 'success',
+ title: 'Thank you for your feedback!',
+ message: 'Your input helps us improve the platform.',
+ });
+ }, 300);
+ };
+
+ return (
+ {
+ setReason('');
+ setMessage('');
+ onClose();
+ }}
+ title="Why did you choose this rating?"
+ size="md"
+ showFooter
+ primaryAction={{
+ label: isSubmitting ? 'Sending...' : 'Submit',
+ onClick: handleSubmit,
+ disabled: isSubmitting || !reason,
+ }}
+ secondaryAction={{
+ label: 'Cancel',
+ onClick: () => {
+ setReason('');
+ setMessage('');
+ onClose();
+ },
+ disabled: isSubmitting,
+ variant: 'outline',
+ }}
+ >
+
+
+
+ Select a reason
+
+
+ {reasons.map(r => (
+
+ setReason(r)}
+ className="h-4 w-4 text-primary"
+ />
+
+ {r}
+
+
+ ))}
+
+
+
+
setMessage(event.target.value)}
+ placeholder="Your feedback helps us improve..."
+ rows={5}
+ description="Please don't include any sensitive information"
+ />
+
+
+ );
+};
+
+interface PageSatisfactionBannerProps {
+ isSidebarCollapsed?: boolean;
+}
+
+export const PageSatisfactionBanner: React.FC = ({
+ isSidebarCollapsed = false,
+}) => {
+ const [modalType, setModalType] = useState<'positive' | 'negative' | null>(
+ null
+ );
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const pathname = usePathname();
+ const { userDetails } = useUserContext();
+ const { showBanner } = useBanner();
+ const { title: pageName, section: pageSection } = usePageTitleContext();
+
+ const handleSubmitFeedback = async (
+ rating: number,
+ reason: string,
+ message: string
+ ): Promise => {
+ const userEmail = userDetails?.email || userDetails?.userName || '';
+
+ if (!userEmail) {
+ showBanner({
+ severity: 'error',
+ title: 'Unable to identify your account',
+ message: 'Please try again in a moment.',
+ scoped: true,
+ });
+ return false;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const metadata = {
+ page: pathname || window.location.pathname,
+ pageTitle: pageName,
+ pageSection,
+ reason,
+ browser:
+ typeof window !== 'undefined'
+ ? navigator.userAgent.slice(0, 80)
+ : 'Unknown',
+ appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
+ };
+
+ await feedbackService.submitFeedback({
+ email: userEmail,
+ subject: `Page Satisfaction: ${pageName}`,
+ message: message || `Rating: ${rating}/5, Reason: ${reason}`,
+ rating,
+ category: 'page_satisfaction',
+ platform: 'web',
+ app: 'vertex',
+ metadata,
+ });
+
+ return true;
+ } catch (error) {
+ showBanner({
+ severity: 'error',
+ title: 'Failed to submit feedback',
+ message:
+ error instanceof Error
+ ? getApiErrorMessage(error)
+ : 'An error occurred while submitting feedback',
+ scoped: true,
+ });
+ return false;
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Overall, how satisfied are you with {pageName}?
+
+
+
+
+ setModalType('positive')}
+ className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
+ aria-label="Satisfied"
+ >
+
+
+
+ setModalType('negative')}
+ className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
+ aria-label="Not satisfied"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ setModalType(null)}
+ onSubmit={handleSubmitFeedback}
+ isSubmitting={isSubmitting}
+ type="positive"
+ />
+
+ setModalType(null)}
+ onSubmit={handleSubmitFeedback}
+ isSubmitting={isSubmitting}
+ type="negative"
+ />
+ >
+ );
+};
diff --git a/src/vertex-template/components/features/feedback/reusable-satisfaction-feedback-toast.tsx b/src/vertex-template/components/features/feedback/reusable-satisfaction-feedback-toast.tsx
new file mode 100644
index 0000000000..a61be72087
--- /dev/null
+++ b/src/vertex-template/components/features/feedback/reusable-satisfaction-feedback-toast.tsx
@@ -0,0 +1,279 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { ThumbsUp, ThumbsDown } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+
+type ToastPhase =
+ | "idle"
+ | "visible"
+ | "negative-expanded"
+ | "submitting"
+ | "thankyou"
+ | "dismissed";
+
+interface ReusableSatisfactionFeedbackToastProps {
+ title: string;
+ subtitle?: string;
+ negativeReasons: string[];
+ onPositiveSubmit: () => Promise | void;
+ onNegativeSubmit: (description?: string) => Promise | void;
+ enabled?: boolean;
+ resetKey?: string | number;
+ positiveLabel?: string;
+ negativeLabel?: string;
+ otherReasonLabel?: string;
+ otherPlaceholder?: string;
+ thankYouTitle?: string;
+ thankYouMessage?: string;
+ showDelayMs?: number;
+ autoDismissMs?: number;
+ onDismiss?: () => void;
+ className?: string;
+}
+
+export const ReusableSatisfactionFeedbackToast: React.FC<
+ ReusableSatisfactionFeedbackToastProps
+> = ({
+ title,
+ subtitle = "Rate us to help improve Vertex",
+ negativeReasons,
+ onPositiveSubmit,
+ onNegativeSubmit,
+ enabled = true,
+ resetKey,
+ positiveLabel = "Great",
+ negativeLabel = "Not great",
+ otherReasonLabel = "Other",
+ otherPlaceholder = "Please provide details (required)",
+ thankYouTitle = "Thank you for your feedback!",
+ thankYouMessage = "Your input helps us improve Vertex.",
+ showDelayMs = 3000,
+ autoDismissMs = 30000,
+ onDismiss,
+ className = "fixed bottom-6 right-6 z-[9999] w-80",
+}) => {
+ const [phase, setPhase] = useState("idle");
+ const [description, setDescription] = useState("");
+ const [otherText, setOtherText] = useState("");
+ const dismissNotifiedRef = useRef(false);
+
+ useEffect(() => {
+ dismissNotifiedRef.current = false;
+ setDescription("");
+ setOtherText("");
+
+ if (!enabled) {
+ setPhase("idle");
+ return;
+ }
+
+ setPhase("idle");
+
+ const showTimer = setTimeout(() => {
+ setPhase((current) => (current === "idle" ? "visible" : current));
+ }, showDelayMs);
+
+ const autoTimer = setTimeout(() => {
+ setPhase((current) => (current === "visible" ? "dismissed" : current));
+ }, showDelayMs + autoDismissMs);
+
+ return () => {
+ clearTimeout(showTimer);
+ clearTimeout(autoTimer);
+ };
+ }, [autoDismissMs, enabled, resetKey, showDelayMs]);
+
+ useEffect(() => {
+ if (phase !== "dismissed" || dismissNotifiedRef.current) return;
+ dismissNotifiedRef.current = true;
+ onDismiss?.();
+ }, [onDismiss, phase]);
+
+ const showThankYouAndDismiss = () => {
+ setPhase("thankyou");
+ setTimeout(() => {
+ setPhase("dismissed");
+ }, 2000);
+ };
+
+ const handlePositive = async () => {
+ setPhase("submitting");
+ try {
+ await onPositiveSubmit();
+ } catch (error) {
+ console.error("Failed to submit positive satisfaction feedback", error);
+ }
+ showThankYouAndDismiss();
+ };
+
+ const handleNegativeSubmit = async () => {
+ setPhase("submitting");
+ try {
+ const finalDescription =
+ description === otherReasonLabel
+ ? `${otherReasonLabel}: ${otherText.trim()}`
+ : description.trim() || undefined;
+
+ await onNegativeSubmit(finalDescription);
+ } catch (error) {
+ console.error("Failed to submit negative satisfaction feedback", error);
+ }
+ showThankYouAndDismiss();
+ };
+
+ const isOtherReason = description === otherReasonLabel;
+ const isSubmitting = phase === "submitting";
+ const isSubmitDisabled =
+ isSubmitting ||
+ !description ||
+ (isOtherReason && !otherText.trim());
+
+ if (phase === "idle" || phase === "dismissed") return null;
+
+ return (
+
+
+
+
+ {phase === "thankyou" ? (
+
+
🎉
+
+ {thankYouTitle}
+
+ {thankYouMessage && (
+
+ {thankYouMessage}
+
+ )}
+
+ ) : (
+ <>
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+
+
+ {positiveLabel}
+
+ {
+ if (phase === "visible") setPhase("negative-expanded");
+ }}
+ disabled={phase === "submitting"}
+ Icon={ThumbsDown}
+ >
+ {negativeLabel}
+
+
+
+
+ {phase === "negative-expanded" && (
+
+
+
+ {negativeReasons.map((reason) => (
+
+ {
+ setDescription(reason);
+ if (reason !== otherReasonLabel) {
+ setOtherText("");
+ }
+ }}
+ disabled={isSubmitting}
+ className="h-4 w-4 text-primary accent-primary"
+ />
+
+ {reason}
+
+
+ ))}
+
+
+ {isOtherReason && (
+
+ setOtherText(e.target.value)}
+ disabled={isSubmitting}
+ />
+
+ )}
+
+
+
+ Submit
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/grids/admin-levels-modal.tsx b/src/vertex-template/components/features/grids/admin-levels-modal.tsx
new file mode 100644
index 0000000000..143e1263be
--- /dev/null
+++ b/src/vertex-template/components/features/grids/admin-levels-modal.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useState } from "react";
+import { useAdminLevels, useUpdateAdminLevel } from "@/core/hooks/useGrids";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { Copy, Edit2, Check, X } from "lucide-react";
+import { useBanner } from "@/context/banner-context";
+import { useClipboard } from "@/core/hooks/useClipboard";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+
+interface AdminLevelsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function AdminLevelsModal({ isOpen, onClose }: AdminLevelsModalProps) {
+ const { adminLevels, isLoading: isLoadingLevels } = useAdminLevels();
+ const { showBanner } = useBanner();
+
+ const [editingId, setEditingId] = useState(null);
+ const [editValue, setEditValue] = useState("");
+
+ const { updateAdminLevel, isLoading: isUpdating } = useUpdateAdminLevel({
+ onSuccess: (data) => {
+ showBanner({
+ message: `Admin level updated to '${data.admin_levels.name}' successfully`,
+ severity: "success",
+ scoped: true,
+ });
+ setEditingId(null);
+ setEditValue("");
+ },
+ onError: (error) => {
+ showBanner({
+ message: `Failed to update admin level: ${getApiErrorMessage(error)}`,
+ severity: "error",
+ scoped: true,
+ });
+ }
+ });
+
+ const { handleCopy: handleCopyId } = useClipboard({ successMessage: 'ID copied to clipboard', errorMessage: 'Failed to copy ID', scoped: true });
+
+ const startEditing = (id: string, name: string) => {
+ setEditingId(id);
+ setEditValue(name);
+ };
+
+ const cancelEditing = () => {
+ setEditingId(null);
+ setEditValue("");
+ };
+
+ const saveEdit = (levelId: string) => {
+ const name = editValue.trim();
+ if (!name) {
+ return;
+ }
+ updateAdminLevel({ levelId, data: { name } });
+ };
+
+ return (
+
+
+
+
+
+ Name
+ ID
+ Actions
+
+
+
+ {isLoadingLevels ? (
+
+
+ Loading admin levels...
+
+
+ ) : adminLevels.length === 0 ? (
+
+
+ No admin levels found.
+
+
+ ) : (
+ adminLevels.map((level) => (
+
+
+ {editingId === level._id ? (
+
+ setEditValue(e.target.value)}
+ className="h-8 py-1"
+ autoFocus
+ />
+
+ ) : (
+ {level.name}
+ )}
+
+
+
+
+ {level._id}
+
+ handleCopyId(level._id)}
+ className="text-muted-foreground hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
+ title="Copy ID"
+ >
+
+
+
+
+
+ {editingId === level._id ? (
+
+ saveEdit(level._id)}
+ disabled={isUpdating}
+ Icon={Check}
+ />
+
+
+ ) : (
+ startEditing(level._id, level.name)}
+ Icon={Edit2}
+ />
+ )}
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/grids/create-admin-level.tsx b/src/vertex-template/components/features/grids/create-admin-level.tsx
new file mode 100644
index 0000000000..d33b0cca86
--- /dev/null
+++ b/src/vertex-template/components/features/grids/create-admin-level.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { Form, FormField } from "@/components/ui/form";
+import { Plus, MoreVertical, List } from "lucide-react";
+import { useCreateAdminLevel } from "@/core/hooks/useGrids";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { AdminLevelsModal } from "./admin-levels-modal";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+const adminLevelFormSchema = z.object({
+ name: z.string().min(2, {
+ message: "Admin level name must be at least 2 characters.",
+ }),
+});
+
+type AdminLevelFormValues = z.infer;
+
+export function CreateAdminLevel() {
+ const [open, setOpen] = useState(false);
+ const [viewModalOpen, setViewModalOpen] = useState(false);
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const form = useForm({
+ resolver: zodResolver(adminLevelFormSchema),
+ defaultValues: {
+ name: "",
+ },
+ });
+
+ const handleClose = () => {
+ setOpen(false);
+ form.reset();
+ };
+
+ const { createAdminLevel, isLoading } = useCreateAdminLevel({
+ onSuccess: () => {
+ handleClose();
+ showBannerWithDelay({ message: "Admin level created successfully", severity: "success", scoped: false });
+ },
+ onError: (error) => {
+ showBanner({
+ message: `Failed to create admin level: ${getApiErrorMessage(error)}`,
+ severity: "error",
+ scoped: true,
+ });
+ },
+ });
+
+ const onSubmit = (data: AdminLevelFormValues) => {
+ createAdminLevel(data);
+ };
+
+ return (
+ <>
+
+ setOpen(true)} Icon={Plus} variant="outlined">
+ New Admin Level
+
+
+
+
+
+
+
+
+
+ setViewModalOpen(true)}>
+
+ View Admin Levels
+
+
+
+
+
+ setViewModalOpen(false)}
+ />
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/vertex-template/components/features/grids/create-grid.tsx b/src/vertex-template/components/features/grids/create-grid.tsx
new file mode 100644
index 0000000000..f8d0dbc4f2
--- /dev/null
+++ b/src/vertex-template/components/features/grids/create-grid.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useState,useEffect } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import dynamic from "next/dynamic";
+import { Form, FormField } from "@/components/ui/form";
+import { Plus } from "lucide-react";
+import { useAppSelector } from "@/core/redux/hooks";
+import { useCreateGrid } from "@/core/hooks/useGrids";
+import { Position } from "@/core/redux/slices/gridsSlice";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+// Lazy load MiniMap to reduce initial bundle size
+const MiniMap = dynamic(() => import("@/components/features/mini-map/mini-map"), {
+ ssr: false,
+ loading: () => (
+
+ Loading map...
+
+ ),
+});
+
+const gridFormSchema = z.object({
+ name: z.string().min(2, {
+ message: "Grid name must be at least 2 characters.",
+ }),
+ administrativeLevel: z.string().min(2, {
+ message: "Administrative level is required.",
+ }),
+ network: z.string().min(1, {
+ message: "Please select a network.",
+ }),
+ shapefile: z.string().refine((val) => {
+ try {
+ const parsed = JSON.parse(val);
+ const t = parsed?.type;
+ const c = parsed?.coordinates;
+ if ((t !== "Polygon" && t !== "MultiPolygon") || !Array.isArray(c)) return false;
+ const isPair = (p: unknown) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === "number");
+ const isRing = (r: unknown) => Array.isArray(r) && r.length >= 4 && r.every(isPair);
+ if (t === "Polygon") return Array.isArray(c) && c.length > 0 && c.every(isRing);
+ // MultiPolygon
+ return Array.isArray(c) && c.length > 0 && c.every((poly: unknown) => Array.isArray(poly) && poly.length > 0 && poly.every(isRing));
+ } catch {
+ return false;
+ }
+ }, { message: "A polygon must be drawn on the map." }),
+});
+
+type GridFormValues = z.infer;
+
+export function CreateGridForm() {
+ const [open, setOpen] = useState(false);
+ const polygon = useAppSelector((state) => state.grids.polygon);
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const { networks, isLoading: isLoadingNetworks } = useNetworks();
+
+ const form = useForm({
+ resolver: zodResolver(gridFormSchema),
+ defaultValues: {
+ name: "",
+ administrativeLevel: "",
+ shapefile: '{"type":"","coordinates":[]}',
+ network: "",
+ },
+ });
+
+ const handleClose = () => {
+ setOpen(false);
+ form.reset();
+ };
+
+ const { createGrid, isLoading } = useCreateGrid({
+ onSuccess: () => {
+ handleClose();
+ showBannerWithDelay({ message: `New grid added!`, severity: "success", scoped: false });
+ },
+ onError: (error) => {
+ showBanner({
+ message: `Failed to create grid: ${getApiErrorMessage(error)}`,
+ severity: "error",
+ scoped: true,
+ });
+ }
+ });
+
+ useEffect(() => {
+ if (polygon) {
+ form.setValue("shapefile", JSON.stringify(polygon), { shouldValidate: true });
+ } else {
+ form.setValue("shapefile", '{"type":"","coordinates":[]}', { shouldValidate: true });
+ }
+ }, [polygon, form]);
+
+ const onSubmit = (data: GridFormValues) => {
+ const gridData = {
+ name: data.name,
+ admin_level: data.administrativeLevel,
+ shape: JSON.parse(data.shapefile) as { type: "MultiPolygon" | "Polygon"; coordinates: Position[][] | Position[][][] },
+ network: data.network,
+ };
+
+ createGrid(gridData);
+ };
+
+ return (
+ <>
+ setOpen(true)} Icon={Plus}>
+ Create Grid
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/vertex-template/components/features/grids/date-range-picker.tsx b/src/vertex-template/components/features/grids/date-range-picker.tsx
new file mode 100644
index 0000000000..f8d6c42460
--- /dev/null
+++ b/src/vertex-template/components/features/grids/date-range-picker.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import * as React from "react";
+import { CalendarIcon } from "lucide-react";
+import { addDays, format } from "date-fns";
+import type { DateRange } from "react-day-picker";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+export function DateRangePicker({
+ className,
+}: React.HTMLAttributes) {
+ const [date, setDate] = React.useState({
+ from: new Date(),
+ to: addDays(new Date(), 7),
+ });
+
+ return (
+
+
+
+
+
+ {date?.from ? (
+ date.to ? (
+ <>
+ {format(date.from, "LLL dd, y")} -{" "}
+ {format(date.to, "LLL dd, y")}
+ >
+ ) : (
+ format(date.from, "LLL dd, y")
+ )
+ ) : (
+ mm/dd/yyyy
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/grids/edit-grid-details-dialog.tsx b/src/vertex-template/components/features/grids/edit-grid-details-dialog.tsx
new file mode 100644
index 0000000000..ab58fe5aea
--- /dev/null
+++ b/src/vertex-template/components/features/grids/edit-grid-details-dialog.tsx
@@ -0,0 +1,105 @@
+import React, { useEffect, useState } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { useUpdateGridDetails } from "@/core/hooks/useGrids";
+import { usePermission } from "@/core/hooks/usePermissions";
+import { PERMISSIONS } from "@/core/permissions/constants";
+import { Grid } from "@/app/types/grids";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+interface EditGridDetailsDialogProps {
+ open: boolean;
+ grid: Grid;
+ onClose: () => void;
+}
+
+const EditGridDetailsDialog: React.FC = ({
+ open,
+ grid,
+ onClose,
+}) => {
+ const [form, setForm] = useState({ name: "", visibility: false, admin_level: "" });
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const { updateGridDetails, isLoading } = useUpdateGridDetails(grid._id, {
+ onSuccess: () => {
+ onClose();
+ showBannerWithDelay({ message: "Grid details updated successfully", severity: "success", scoped: false });
+ },
+ onError: (error) => {
+ showBanner({
+ message: `Failed to update grid: ${getApiErrorMessage(error)}`,
+ severity: "error",
+ scoped: true,
+ });
+ }
+ });
+
+ const canUpdate = usePermission(PERMISSIONS.SITE.UPDATE);
+
+ useEffect(() => {
+ if (grid) {
+ setForm({ name: grid.name, visibility: grid.visibility, admin_level: grid.admin_level });
+ }
+ }, [grid]);
+
+ useEffect(() => {
+ if (!open && grid) {
+ setForm({ name: grid.name, visibility: grid.visibility, admin_level: grid.admin_level });
+ }
+ }, [open, grid]);
+
+ const handleSave = async () => {
+ const trimmedName = form.name.trim();
+ const trimmedAdminLevel = form.admin_level.trim();
+
+ if (trimmedName.length === 0 || trimmedAdminLevel.length === 0) {
+ showBanner({
+ severity: "error",
+ message: "Grid Name and Admin Level cannot be empty.",
+ scoped: true,
+ });
+ return;
+ }
+
+ // If no changes were made, just close the dialog.
+ if (trimmedName === grid.name && trimmedAdminLevel === grid.admin_level && form.visibility === grid.visibility) {
+ onClose();
+ return;
+ }
+
+ const updates: { name?: string; visibility?: boolean; admin_level: string } = {
+ admin_level: trimmedAdminLevel,
+ };
+ if (trimmedName !== grid.name) updates.name = trimmedName;
+ if (form.visibility !== grid.visibility) updates.visibility = form.visibility;
+
+ try {
+ await updateGridDetails(updates);
+ } catch {
+ return;
+ }
+ };
+
+ return (
+
+
+ setForm((s) => ({ ...s, name: e.target.value }))} placeholder="Enter grid name" disabled={isLoading} required />
+ setForm((s) => ({ ...s, admin_level: e.target.value }))} placeholder="e.g. Country, Region" disabled={isLoading} required />
+ setForm((s) => ({ ...s, visibility: e.target.value === "true" }))} disabled={isLoading} required >
+ Public
+ Private
+
+
+
+ );
+};
+
+export default EditGridDetailsDialog;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/grids/grid-details-card.tsx b/src/vertex-template/components/features/grids/grid-details-card.tsx
new file mode 100644
index 0000000000..0498eea523
--- /dev/null
+++ b/src/vertex-template/components/features/grids/grid-details-card.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Loader2 } from "lucide-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqCopy01, AqEdit01 } from "@airqo/icons-react";
+import { Grid } from "@/app/types/grids";
+import { useClipboard } from "@/core/hooks/useClipboard";
+
+interface GridDetailsCardProps {
+ grid: Grid;
+ onEdit: () => void;
+ loading: boolean;
+}
+
+const GridDetailsCard: React.FC = ({ grid, onEdit, loading }) => {
+ const { handleCopy } = useClipboard({ successMessage: 'Copied to clipboard', errorMessage: 'Failed to copy to clipboard' });
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
Grid Details
+
+
+
Grid Name
+
{grid.name || "-"}
+
+
+
+
Admin Level
+
{grid.admin_level || "-"}
+
+
+
+
Visibility
+
+ {grid.visibility ? "Public" : "Private"}
+
+
+
+
+
Grid ID
+
+
{grid._id || "-"}
+
handleCopy(grid._id)} className="p-1" Icon={AqCopy01} />
+
+
+
+
+
+ Edit Details
+
+
+
+ );
+};
+
+export default GridDetailsCard;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/grids/grid-measurements-api-card.tsx b/src/vertex-template/components/features/grids/grid-measurements-api-card.tsx
new file mode 100644
index 0000000000..90b736178d
--- /dev/null
+++ b/src/vertex-template/components/features/grids/grid-measurements-api-card.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Loader2 } from "lucide-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqCopy01 } from "@airqo/icons-react";
+import { Grid } from "@/app/types/grids";
+import { useClipboard } from "@/core/hooks/useClipboard";
+
+interface GridMeasurementsApiCardProps {
+ grid: Grid;
+ loading: boolean;
+}
+
+const GridMeasurementsApiCard: React.FC = ({ grid, loading }) => {
+ const { handleCopy } = useClipboard({ successMessage: 'API URL copied!', errorMessage: 'Failed to copy to clipboard' });
+
+ if (loading) {
+ return ;
+ }
+
+ const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.airqo.net").replace(/\/$/, "");
+ const recentApiUrl = `${apiBase}/api/v2/devices/measurements/${grid._id}`;
+ const historicalApiUrl = `${apiBase}/api/v2/devices/measurements/grids/${grid._id}`;
+
+ return (
+
+ Grid Measurements API
+ {/* Recent Measurements */}
+
+
Recent Measurements API
+
+
+ {`${recentApiUrl}?token=YOUR_TOKEN`}
+
+
handleCopy(`${recentApiUrl}?token=YOUR_TOKEN`)} Icon={AqCopy01} />
+
+
+ {/* Historical Measurements */}
+
+
Historical Measurements API
+
+
+ {`${historicalApiUrl}?token=YOUR_TOKEN`}
+
+
handleCopy(`${historicalApiUrl}?token=YOUR_TOKEN`)} Icon={AqCopy01} />
+
+
+
+ );
+};
+
+export default GridMeasurementsApiCard;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/grids/grids-list-table.tsx b/src/vertex-template/components/features/grids/grids-list-table.tsx
new file mode 100644
index 0000000000..e9c6019370
--- /dev/null
+++ b/src/vertex-template/components/features/grids/grids-list-table.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useGrids } from "@/core/hooks/useGrids";
+import { Grid } from "@/app/types/grids";
+import ReusableTable, {
+ TableColumn,
+ TableItem,
+} from "@/components/shared/table/ReusableTable";
+import { format, parseISO } from "date-fns";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import { useMemo, useEffect } from "react";
+
+type TableGrid = TableItem & Grid;
+
+interface GridsTableProps {
+ itemsPerPage?: number;
+ onGridClick?: (grid: Grid) => void;
+ className?: string;
+}
+
+export default function GridsTable({
+ itemsPerPage = 25,
+ onGridClick,
+ className,
+}: GridsTableProps) {
+ const router = useRouter();
+
+ const {
+ pagination, setPagination,
+ searchTerm, setSearchTerm,
+ sorting, setSorting
+ } = useServerSideTableState({ initialPageSize: itemsPerPage });
+
+ const { grids, meta, isFetching, error } = useGrids({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ });
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ useEffect(() => {
+ if (pageCount > 0 && pagination.pageIndex >= pageCount) {
+ setPagination((prev) => ({
+ ...prev,
+ pageIndex: Math.max(0, pageCount - 1),
+ }));
+ }
+ }, [pageCount, pagination.pageIndex, setPagination]);
+
+
+ const handleRowClick = (item: unknown) => {
+ const grid = item as Grid;
+ if (onGridClick) onGridClick(grid);
+ else if (grid._id) router.push(`/admin/grids/${grid._id}`);
+ };
+
+ const data: TableGrid[] = useMemo(() => (grids || [])
+ .filter((g: Grid): g is Grid & { _id: string } => typeof g._id === "string" && g._id.trim() !== "")
+ .map((g: Grid) => ({ ...g, id: g._id })), [grids]);
+
+ const columns: TableColumn[] = [
+ { key: "name", label: "Grid Name", render: (value) => String(value ?? "").toUpperCase(), sortable: true },
+ { key: "numberOfSites", label: "Number of sites", render: (v) => String(v ?? 0), sortable: true },
+ { key: "admin_level", label: "Admin level", render: (v) => String(v ?? ""), sortable: true },
+ { key: "visibility", label: "Visibility", render: (v) => (v ? "Visible" : "Hidden"), sortable: true },
+ { key: "createdAt", label: "Date created", render: (v) => v ? format(parseISO(v as string), "MMM d yyyy, h:mm a") : "", sortable: true },
+ ];
+
+ return (
+
+
+
+ Unable to load grids
+ {error.message}
+
+ ) : "No grids available"
+ }
+ serverSidePagination
+ pageCount={pageCount}
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ onSearchChange={setSearchTerm}
+ searchTerm={searchTerm}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ searchable
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/home/HomeEmptyState.tsx b/src/vertex-template/components/features/home/HomeEmptyState.tsx
new file mode 100644
index 0000000000..ef3f3aedbd
--- /dev/null
+++ b/src/vertex-template/components/features/home/HomeEmptyState.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Plus, Upload } from 'lucide-react';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { AqCollocation } from '@airqo/icons-react';
+import dynamic from 'next/dynamic';
+
+const ClaimDeviceModal = dynamic(
+ () => import('../claim/claim-device-modal'),
+ { ssr: false }
+);
+
+const ImportDeviceModal = dynamic(
+ () => import('../devices/import-device-modal'),
+ { ssr: false }
+);
+
+import ContextHeader from './context-header';
+
+const HomeEmptyState = () => {
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+ No Devices Found
+
+
+
+ Add your AirQo devices or import external devices from any Sensor Manufacturer to begin tracking your device fleet.
+
+
+
+ setIsClaimModalOpen(true)} Icon={Plus}>
+ Add AirQo Device
+
+
+ setIsImportModalOpen(true)}
+ Icon={Upload}
+ >
+ Import External Device
+
+
+
+
setIsClaimModalOpen(false)}
+ />
+
+
+
+ );
+};
+
+export default HomeEmptyState;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/home/context-header.tsx b/src/vertex-template/components/features/home/context-header.tsx
new file mode 100644
index 0000000000..031ed042b4
--- /dev/null
+++ b/src/vertex-template/components/features/home/context-header.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { useAppSelector } from "@/core/redux/hooks";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { InfoBanner } from "@/components/ui/banner";
+
+const ContextHeader = () => {
+ const { userContext } = useUserContext();
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+ const userDetails = useAppSelector((state) => state.user.userDetails);
+
+ // Get user's first name, fallback to a generic greeting if not available
+ const firstName = userDetails?.firstName || userDetails?.userName?.split('@')[0] || "User";
+
+ const getContextTitle = () => {
+ switch (userContext) {
+ case "personal":
+ return "your personal workspace";
+ case "external-org":
+ return activeGroup?.grp_title
+ ? `${activeGroup.grp_title.split("_").join(" ")} workspace`
+ : "organization workspace";
+ default:
+ return "Dashboard";
+ }
+ };
+
+ const getContextDescription = () => {
+ switch (userContext) {
+ case "personal":
+ return "Devices and data here are owned by you.";
+ case "external-org":
+ return `View and manage all devices linked to the organization.`;
+ default:
+ return "Welcome to your dashboard.";
+ }
+ };
+
+ return (
+
+
+ Hi, {firstName} 👋
+
+
+
+ You're in {getContextTitle()}. {" "}
+ {getContextDescription()}
+
+ }
+ />
+
+ );
+};
+
+export default ContextHeader;
diff --git a/src/vertex-template/components/features/home/network-visibility-card.tsx b/src/vertex-template/components/features/home/network-visibility-card.tsx
new file mode 100644
index 0000000000..0dd90264d5
--- /dev/null
+++ b/src/vertex-template/components/features/home/network-visibility-card.tsx
@@ -0,0 +1,330 @@
+import React, { useEffect, useState } from 'react';
+import { Globe, Lock, Shield } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import {
+ useCohorts,
+ useGroupCohorts,
+ usePersonalUserCohorts,
+} from '@/core/hooks/useCohorts';
+import { cohorts as cohortsApi } from '@/core/apis/cohorts';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { PERMISSIONS } from '@/core/permissions/constants';
+import ReusableToast from '@/components/shared/toast/ReusableToast';
+import { useQueryClient } from '@tanstack/react-query';
+import { usePermissions } from '@/core/hooks/usePermissions';
+import { cn } from '@/lib/utils';
+import { useRouter } from 'next/navigation';
+import { useUserContext } from '@/core/hooks/useUserContext';
+import { useSession } from 'next-auth/react';
+import { useAppSelector } from '@/core/redux/hooks';
+import { Cohort } from '@/app/types/cohorts';
+
+interface NetworkVisibilityCardProps {
+ onVisibilityChanged?: () => void;
+ showCoachMark?: boolean;
+}
+
+const NetworkVisibilityCard = ({ onVisibilityChanged, showCoachMark }: NetworkVisibilityCardProps) => {
+ const queryClient = useQueryClient();
+ const router = useRouter();
+ const { data: session } = useSession();
+ const user = useAppSelector(state => state.user.userDetails);
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [targetVisibility, setTargetVisibility] = useState
(false);
+ const [pendingCohort, setPendingCohort] = useState(null);
+ const [isUpdating, setIsUpdating] = useState(false);
+ const [isCoachMarkVisible, setIsCoachMarkVisible] = useState(false);
+ const [successTooltip, setSuccessTooltip] = useState<{
+ cohortId: string;
+ message: string;
+ } | null>(null);
+
+ const { activeGroup, isExternalOrg, userScope } = useUserContext();
+
+ const isPersonalScope = userScope === 'personal';
+ const userId = (session?.user as { id?: string })?.id || user?._id;
+
+ // --- Personal scope: fetch cohort IDs via personal user cohorts API ---
+ const { data: personalCohortIds = [], isLoading: isLoadingPersonalCohorts } =
+ usePersonalUserCohorts(userId, {
+ enabled: isPersonalScope && !!userId,
+ });
+
+ // --- Org scope: fetch group cohort IDs ---
+ const { data: groupCohortIds, isLoading: isLoadingGroupCohorts } =
+ useGroupCohorts(activeGroup?._id, {
+ enabled: isExternalOrg && !!activeGroup?._id && !isPersonalScope,
+ });
+
+ // Resolve which cohort IDs to use
+ const effectiveCohortIds = isPersonalScope
+ ? personalCohortIds
+ : groupCohortIds;
+ const hasIdsToFetch = !!effectiveCohortIds && effectiveCohortIds.length > 0;
+
+ // Fetch full cohort details for the resolved IDs
+ const { cohorts, isLoading: isLoadingCohorts } = useCohorts(
+ {
+ limit: 1000,
+ cohort_id: hasIdsToFetch ? effectiveCohortIds : undefined,
+ },
+ { enabled: hasIdsToFetch }
+ );
+
+ const isLoading =
+ (isPersonalScope && isLoadingPersonalCohorts) ||
+ (isExternalOrg && !isPersonalScope && isLoadingGroupCohorts) ||
+ isLoadingCohorts;
+
+ const hasDeviceUpdatePermission = usePermissions([PERMISSIONS.DEVICE.UPDATE])[
+ PERMISSIONS.DEVICE.UPDATE
+ ];
+
+ useEffect(() => {
+ if (showCoachMark) {
+ setIsCoachMarkVisible(true);
+ const t = setTimeout(() => setIsCoachMarkVisible(false), 5000);
+ return () => clearTimeout(t);
+ }
+ }, [showCoachMark]);
+
+ useEffect(() => {
+ if (!successTooltip) return;
+
+ const t = setTimeout(() => setSuccessTooltip(null), 4000);
+ return () => clearTimeout(t);
+ }, [successTooltip]);
+
+ if (!isLoading && (!cohorts || cohorts.length === 0)) return null;
+ if (isLoading) return null;
+
+ const allPublic =
+ cohorts.length > 0 && cohorts.every(c => c.visibility === true);
+ const allPrivate =
+ cohorts.length > 0 && cohorts.every(c => c.visibility === false);
+
+ const handleRunningUpdate = async () => {
+ if (!pendingCohort) return;
+
+ setIsUpdating(true);
+ try {
+ await cohortsApi.updateCohortDetailsApi(pendingCohort._id, {
+ visibility: targetVisibility,
+ });
+
+ setSuccessTooltip({
+ cohortId: pendingCohort._id,
+ message: `${pendingCohort.name} is now ${targetVisibility ? 'public' : 'private'}`,
+ });
+ onVisibilityChanged?.();
+
+ queryClient.invalidateQueries({ queryKey: ['cohorts'] });
+ // Also invalidate personal cohorts cache if in personal scope
+ if (isPersonalScope) {
+ queryClient.invalidateQueries({
+ queryKey: ['personalUserCohorts', userId],
+ });
+ }
+ } catch (error) {
+ console.error(error);
+ ReusableToast({
+ message: 'Failed to update network visibility',
+ type: 'ERROR',
+ });
+ } finally {
+ setIsUpdating(false);
+ setIsDialogOpen(false);
+ setPendingCohort(null);
+ }
+ };
+
+
+ return (
+ <>
+
+
+
+
+ {allPublic ? (
+
+ ) : allPrivate ? (
+
+ ) : (
+
+ )}
+
+
+
+ {allPublic
+ ? `${isPersonalScope ? 'Your' : 'Organization'} devices are Public`
+ : allPrivate
+ ? `${isPersonalScope ? 'Your' : 'Organization'} devices are Private`
+ : 'Custom Visibility Settings'}
+
+
+ {allPublic
+ ? `${isPersonalScope ? 'Your' : "Your organization's"} devices are visible to anyone on the AirQo Map. You are contributing to open data.`
+ : allPrivate
+ ? `${isPersonalScope ? 'Your' : "Your organization's"} devices are hidden from the public. Data is visible only in your account.`
+ : 'Visibility settings are customized per cohort. Manage each cohort below.'}
+
+
+
+
+
+
+
+ Manage Cohorts Visibility
+
+
+ {cohorts.map((cohort, index) => (
+
+
+ {/* Status dot with spinning border when private */}
+
+
+
+ {cohort.name}
+
+
+
+
+
+ {cohort.visibility ? 'Public' : 'Private'}
+
+
+ {/* Switch + coach mark tooltip */}
+
+ {/* Coach mark — only on first private cohort, auto-dismisses */}
+ {successTooltip?.cohortId === cohort._id ? (
+
+
+ {successTooltip.message}
+
+
+
+ ) : isCoachMarkVisible && !cohort.visibility && index === cohorts.findIndex(c => !c.visibility) && (
+
+
+ Toggle to make public
+ {/* Arrow pointing down */}
+
+
+
+ )}
+
+
+
+ {
+ setPendingCohort(cohort);
+ setTargetVisibility(checked);
+ setIsDialogOpen(true);
+ }}
+ disabled={!hasDeviceUpdatePermission}
+ className="data-[state=checked]:bg-green-500"
+ />
+
+
+
+
+
+ ))}
+
+
+
+
+ router.push('/cohorts')}
+ className="text-primary hover:bg-primary/5"
+ >
+ View All Cohorts Details
+
+
+
+
+ !isUpdating && setIsDialogOpen(false)}
+ title={
+ targetVisibility
+ ? `Make "${pendingCohort?.name}" public?`
+ : `Make "${pendingCohort?.name}" private?`
+ }
+ size="md"
+ customFooter={
+
+ setIsDialogOpen(false)}
+ disabled={isUpdating}
+ >
+ Cancel
+
+
+ {isUpdating
+ ? 'Updating...'
+ : targetVisibility
+ ? 'Confirm & Publish'
+ : 'Confirm & Make Private'}
+
+
+ }
+ >
+
+
+ {targetVisibility
+ ? `You are about to make the devices in "${pendingCohort?.name}" visible on the public AirQo Map. This means anyone can see the air quality readings for these devices.`
+ : `You are about to make "${pendingCohort?.name}" private. Data from these devices will only be visible to ${isPersonalScope ? 'you' : 'your organization'} and will not appear on the public map.`}
+
+
+
+ >
+ );
+};
+
+export default NetworkVisibilityCard;
diff --git a/src/vertex-template/components/features/home/onboarding-checklist.tsx b/src/vertex-template/components/features/home/onboarding-checklist.tsx
new file mode 100644
index 0000000000..6e727526b8
--- /dev/null
+++ b/src/vertex-template/components/features/home/onboarding-checklist.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import React from "react";
+import {
+ CheckCircle2,
+ Circle,
+ ArrowRight,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type ChecklistStep = {
+ id: string;
+ title: string;
+ description: string;
+ cta: string;
+ heuristic: string;
+};
+
+type OnboardingChecklistProps = {
+ onAddDevice: () => void;
+ onGoToCohorts: () => void;
+ onGoToVisibility?: () => void;
+ completedSteps: string[];
+ onDismiss: () => void;
+ onMarkAsDone?: () => void;
+ organizationName?: string;
+ isReadOnly?: boolean;
+};
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const STEPS: ChecklistStep[] = [
+ {
+ id: "add-device",
+ title: "Add your first device",
+ description:
+ "Register your new AirQo sensor or add a device from a different sensor manufacturer (Clarity, PurpleAir, and others).",
+ cta: "Add a device",
+ heuristic: "Start here",
+ },
+ {
+ id: "assign-cohort",
+ title: "Group your devices",
+ description:
+ "Organise your devices into groups so you can manage them together. Imports complete this step when devices are assigned to a group.",
+ cta: "Group devices",
+ heuristic: "Step 2",
+ },
+ {
+ id: "set-visibility",
+ title: "Share your data",
+ description:
+ "Choose who can see your device data — keep it private or make it public. You can change this any time.",
+ cta: "Set sharing",
+ heuristic: "Step 3",
+ },
+];
+
+// ─── StepRow ──────────────────────────────────────────────────────────────────
+
+type StepRowProps = {
+ step: ChecklistStep;
+ isComplete: boolean;
+ isLocked: boolean;
+ isReadOnly?: boolean;
+ onAction: () => void;
+};
+
+const StepRow: React.FC = ({
+ step,
+ isComplete,
+ isLocked,
+ isReadOnly,
+ onAction,
+}) => {
+ // Active = current actionable step (not done, not locked)
+ const isActive = !isComplete && !isLocked;
+
+ return (
+
+ {/* Status icon — H1: Visibility of system status */}
+
+ {isComplete ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Content */}
+
+
+ {/* Badge — H6: Recognition over recall, makes sequence obvious */}
+
+ {isComplete ? "Done" : step.heuristic}
+
+
+
+ {step.title}
+
+
+
+ {/* Description — visible for all incomplete steps so user knows what's coming */}
+ {!isComplete && (
+
+ {step.description}
+
+ )}
+
+
+ {/* CTA — only on the active step */}
+ {isActive && (
+
+ {step.cta}
+ {!isReadOnly && }
+
+ )}
+
+ );
+};
+
+// ─── ProgressBar ──────────────────────────────────────────────────────────────
+
+const ProgressBar: React.FC<{ completed: number; total: number }> = ({
+ completed,
+ total,
+}) => {
+ const pct = Math.round((completed / total) * 100);
+
+ return (
+
+
+
+ {completed}/{total} done
+
+
+ );
+};
+
+// ─── OnboardingChecklist ──────────────────────────────────────────────────────
+
+export const OnboardingChecklist: React.FC = ({
+ onAddDevice,
+ onGoToCohorts,
+ onGoToVisibility,
+ completedSteps,
+ onDismiss,
+ onMarkAsDone,
+ organizationName,
+ isReadOnly,
+}) => {
+
+ const [showingSuccess, setShowingSuccess] = React.useState(false);
+
+ const completedCount = completedSteps.length;
+ const allComplete = completedCount === STEPS.length;
+
+ // Map each step ID to its action handler
+ const stepActions: Record void> = {
+ "add-device": onAddDevice,
+ "assign-cohort": onGoToCohorts,
+ "set-visibility": onGoToVisibility ?? (() => {}),
+ };
+
+ // A step is locked (upcoming) only if the previous step isn't done yet.
+ // Locked steps are visible and readable — they are NOT disabled or greyed out
+ // to the point of being invisible. They simply have no CTA until unlocked.
+ const isStepLocked = (index: number): boolean => {
+ if (index === 0) return false;
+ return !completedSteps.includes(STEPS[index - 1].id);
+ };
+
+ // Auto-dismiss after all steps complete OR after showing success state
+ React.useEffect(() => {
+ if (allComplete || showingSuccess) {
+ const timer = setTimeout(() => {
+ onDismiss();
+ onMarkAsDone?.();
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [allComplete, showingSuccess, onDismiss, onMarkAsDone]);
+
+ return (
+
+ {/* ── Header ── */}
+
+
+
+ {allComplete || showingSuccess
+ ? "You're all set! 🎉"
+ : `Finish setting up your ${organizationName || "Personal"} workspace`}
+
+ {/* Progress always visible even when collapsed — H1 */}
+ {!showingSuccess &&
}
+
+
+
+ {isReadOnly && !allComplete && !showingSuccess && (
+
+ View-Only Mode: You need Admin permissions to complete these steps. Please contact your workspace administrator to finish setup.
+
+ )}
+
+ {/* ── Steps — H6: Full journey visible at once, recognition over recall ── */}
+ {!showingSuccess && (
+
+ {STEPS.map((step, index) => (
+
+ ))}
+
+ )}
+
+ {/* ── Mark as done — visible always, enabled once steps 1 & 2 are complete ── */}
+ {!showingSuccess && onMarkAsDone && (
+ <>
+
+
+ {
+ setShowingSuccess(true);
+ onMarkAsDone?.();
+ }}
+ disabled={!completedSteps.includes("add-device") || !completedSteps.includes("assign-cohort")}
+ className={"text-sm font-medium px-2 py-1 rounded-md transition-colors duration-150"}
+ aria-label="Dismiss setup checklist"
+ title={
+ !completedSteps.includes("add-device") || !completedSteps.includes("assign-cohort")
+ ? "Complete steps 1 and 2 first"
+ : "Dismiss the setup checklist"
+ }
+ >
+ Mark as done
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default OnboardingChecklist;
diff --git a/src/vertex-template/components/features/location-autocomplete/LocationAutocomplete.tsx b/src/vertex-template/components/features/location-autocomplete/LocationAutocomplete.tsx
new file mode 100644
index 0000000000..2353404612
--- /dev/null
+++ b/src/vertex-template/components/features/location-autocomplete/LocationAutocomplete.tsx
@@ -0,0 +1,196 @@
+import logger from '@/lib/logger';
+import { MapPinIcon } from 'lucide-react';
+import React, { useState, useEffect, useRef } from 'react';
+
+interface LocationSuggestion {
+ id: string;
+ place_name: string;
+ center: [number, number]; // [lng, lat]
+ text: string;
+ place_type: string[];
+}
+
+interface LocationAutocompleteProps {
+ value: string;
+ onChange: (value: string) => void;
+ onLocationSelect: (location: { name: string; latitude: number; longitude: number }) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ error?: string;
+}
+
+const LocationAutocomplete: React.FC = ({
+ value,
+ onChange,
+ onLocationSelect,
+ placeholder = "Enter location name",
+ disabled = false,
+ error,
+}) => {
+ const [suggestions, setSuggestions] = useState([]);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [fetchError, setFetchError] = useState(null);
+ const inputRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const debounceTimeoutRef = useRef | null>(null);
+
+ const MAPBOX_ACCESS_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ !inputRef.current?.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ if (debounceTimeoutRef.current) {
+ clearTimeout(debounceTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const fetchLocationSuggestions = async (query: string) => {
+ if (!query.trim() || query.length < 2) {
+ setSuggestions([]);
+ setIsOpen(false);
+ return;
+ }
+
+ setIsLoading(true);
+ setFetchError(null);
+
+ try {
+ const response = await fetch(
+ `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
+ query
+ )}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=5&types=place,locality,neighborhood,address`
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch location suggestions');
+ }
+
+ const data = await response.json();
+ setSuggestions(data.features || []);
+ setIsOpen(true);
+ } catch (err) {
+ logger.error('Error fetching location suggestions:', { error: err instanceof Error ? err.message : String(err) });
+ setFetchError('Failed to load suggestions');
+ setSuggestions([]);
+ setIsOpen(true);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ onChange(newValue);
+
+ if (debounceTimeoutRef.current) {
+ clearTimeout(debounceTimeoutRef.current);
+ }
+
+ debounceTimeoutRef.current = setTimeout(() => {
+ fetchLocationSuggestions(newValue);
+ }, 300);
+ };
+
+ const handleSuggestionClick = (suggestion: LocationSuggestion) => {
+ const [longitude, latitude] = suggestion.center;
+ onChange(suggestion.place_name);
+ onLocationSelect({
+ name: suggestion.place_name,
+ latitude,
+ longitude,
+ });
+ setIsOpen(false);
+ setSuggestions([]);
+ };
+
+ const handleInputFocus = () => {
+ if (value.trim() && (suggestions.length > 0 || fetchError)) {
+ setIsOpen(true);
+ }
+ };
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isOpen && (suggestions.length > 0 || fetchError) && (
+
+ {fetchError ? (
+
+ {fetchError}
+
+ ) : (
+ suggestions.map((suggestion) => (
+
handleSuggestionClick(suggestion)}
+ className="w-full px-4 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ >
+
+
+
+
+ {suggestion.text}
+
+
+ {suggestion.place_name}
+
+
+
+
+ ))
+ )}
+
+ )}
+ {error && (
+
+ )}
+
+ );
+};
+
+export default LocationAutocomplete;
diff --git a/src/vertex-template/components/features/mini-map/mini-map.tsx b/src/vertex-template/components/features/mini-map/mini-map.tsx
new file mode 100644
index 0000000000..c84a9a75f3
--- /dev/null
+++ b/src/vertex-template/components/features/mini-map/mini-map.tsx
@@ -0,0 +1,280 @@
+"use client";
+
+import mapboxgl from 'mapbox-gl';
+import 'mapbox-gl/dist/mapbox-gl.css';
+import { useEffect, useRef, useState, useCallback } from "react";
+import { useDispatch } from 'react-redux';
+import { setPolygon } from '@/core/redux/slices/gridsSlice';
+import MapboxDraw from '@mapbox/mapbox-gl-draw';
+import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
+import { cn } from '@/lib/utils';
+
+mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN || '';
+
+interface MiniMapProps {
+ latitude?: string;
+ longitude?: string;
+ onCoordinateChange?: (lat: string, lng: string) => void;
+ onSiteNameChange?: (name: string) => void;
+ inputMode?: 'siteName' | 'coordinates';
+ customSiteName?: string;
+ mapMode?: 'marker' | 'polygon';
+ center?: [number, number];
+ zoom?: number;
+ scrollZoom?: boolean;
+ height?: string;
+}
+
+const DEFAULT_CENTER: [number, number] = [32.58252, 0.347596]; // Kampala
+const DEFAULT_ZOOM = 10;
+
+// Reverse geocoding function
+const reverseGeocode = async (lng: number, lat: number): Promise => {
+ if (!mapboxgl.accessToken) {
+ console.error('❌ No Mapbox access token found!');
+ return `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
+ }
+
+ try {
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxgl.accessToken}&types=place,locality,neighborhood,address,poi`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ console.error('❌ Response not ok:', response.status, response.statusText);
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ if (data.features && data.features.length > 0) {
+ // Priority order: place > locality > neighborhood > poi > address
+ type MapboxFeature = {
+ place_type?: string[];
+ text?: string;
+ place_name?: string;
+ };
+
+ const featuresByType = {
+ place: data.features.find((f: MapboxFeature) => f.place_type?.includes('place')),
+ locality: data.features.find((f: MapboxFeature) => f.place_type?.includes('locality')),
+ neighborhood: data.features.find((f: MapboxFeature) => f.place_type?.includes('neighborhood')),
+ poi: data.features.find((f: MapboxFeature) => f.place_type?.includes('poi')),
+ address: data.features.find((f: MapboxFeature) => f.place_type?.includes('address'))
+ };
+
+ // Get the best available feature
+ const bestFeature = featuresByType.place ||
+ featuresByType.locality ||
+ featuresByType.neighborhood ||
+ featuresByType.poi ||
+ featuresByType.address ||
+ data.features[0];
+
+ if (bestFeature) {
+ let placeName;
+ // For better naming, prefer text over place_name for certain types
+ if (bestFeature.place_type?.includes('place') || bestFeature.place_type?.includes('locality')) {
+ placeName = bestFeature.text || bestFeature.place_name;
+ } else {
+ placeName = bestFeature.place_name || bestFeature.text;
+ }
+
+ return placeName || `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
+ }
+ } else {
+ console.log('⚠️ No features found in response');
+ }
+ } catch (error) {
+ console.error('❌ Reverse geocoding failed:', error);
+ }
+
+ const fallbackName = `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
+ return fallbackName;
+};
+
+function MiniMap({
+ latitude,
+ longitude,
+ onCoordinateChange,
+ onSiteNameChange,
+ customSiteName,
+ mapMode = 'marker',
+ center = DEFAULT_CENTER,
+ zoom = DEFAULT_ZOOM,
+ scrollZoom = true,
+ height = 'h-72',
+}: MiniMapProps) {
+ const mapRef = useRef(null);
+ const mapContainerRef = useRef(null);
+ const markerRef = useRef(null);
+ const drawRef = useRef(null);
+ const [isGeocoding, setIsGeocoding] = useState(false);
+ const dispatch = useDispatch();
+
+ // Handle coordinate updates from map interaction
+ const handleCoordinateUpdate = useCallback(async (lat: number, lng: number) => {
+ if (onCoordinateChange) {
+ onCoordinateChange(lat.toString(), lng.toString());
+ }
+ if (!onSiteNameChange) {
+ return;
+ }
+
+ setIsGeocoding(true);
+ try {
+ const placeName = await reverseGeocode(lng, lat);
+ if (placeName && !placeName.startsWith('Location at ')) {
+ onSiteNameChange(placeName);
+ } else {
+ if (customSiteName && customSiteName.trim()) {
+ onSiteNameChange(customSiteName);
+ } else {
+ onSiteNameChange(`Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get place name:', error);
+ if (customSiteName && customSiteName.trim()) {
+ onSiteNameChange(customSiteName);
+ } else {
+ onSiteNameChange(`Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
+ }
+ } finally {
+ setIsGeocoding(false);
+ }
+ }, [onCoordinateChange, onSiteNameChange, customSiteName]);
+
+ useEffect(() => {
+ if (!mapContainerRef.current || !mapboxgl.accessToken || mapRef.current) return;
+
+ const latNum = latitude?.trim() === '' ? NaN : Number(latitude);
+ const lngNum = longitude?.trim() === '' ? NaN : Number(longitude);
+ const hasLatLng = Number.isFinite(latNum) && Number.isFinite(lngNum);
+ const currentCenter: [number, number] = hasLatLng ? [lngNum, latNum] : center;
+
+ const map = new mapboxgl.Map({
+ container: mapContainerRef.current,
+ style: 'mapbox://styles/mapbox/streets-v12',
+ center: currentCenter,
+ zoom: zoom,
+ scrollZoom: scrollZoom,
+ trackResize: true,
+ });
+
+ mapRef.current = map;
+
+ map.on('load', () => {
+ if (!mapRef.current) return;
+
+ map.addControl(new mapboxgl.NavigationControl());
+
+ if (mapMode === 'marker') {
+ markerRef.current = new mapboxgl.Marker({ draggable: true })
+ .setLngLat(currentCenter)
+ .addTo(map);
+
+ markerRef.current.on('dragend', () => {
+ const lngLat = markerRef.current?.getLngLat();
+ if (lngLat) {
+ handleCoordinateUpdate(lngLat.lat, lngLat.lng);
+ }
+ });
+
+ map.on('click', (event) => {
+ const { lng, lat } = event.lngLat;
+ markerRef.current?.setLngLat([lng, lat]);
+ handleCoordinateUpdate(lat, lng);
+ });
+ } else if (mapMode === 'polygon') {
+ const draw = new MapboxDraw({
+ displayControlsDefault: false,
+ controls: {
+ polygon: true,
+ trash: true,
+ },
+ defaultMode: 'draw_polygon',
+ });
+ drawRef.current = draw;
+ map.addControl(draw);
+
+ const updateArea = () => {
+ const data = draw.getAll();
+ if (data.features.length > 1) {
+ const idsToDelete = data.features.slice(0, -1).map((f: { id?: string | number }) => f.id as string);
+ draw.delete(idsToDelete);
+ return;
+ }
+
+ if (data.features.length > 0) {
+ const feature = data.features[0];
+ const geometry = feature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
+ if (geometry.type === 'Polygon') {
+ const transformed = geometry.coordinates.map(ring =>
+ ring.map(([lng, lat]) => [lat, lng] as [number, number])
+ );
+ dispatch(setPolygon({ type: 'Polygon', coordinates: transformed }));
+ } else if (geometry.type === 'MultiPolygon') {
+ const transformed = geometry.coordinates.map(polygon =>
+ polygon.map(ring => ring.map(([lng, lat]) => [lat, lng] as [number, number]))
+ );
+ dispatch(setPolygon({ type: 'MultiPolygon', coordinates: transformed }));
+ }
+ } else {
+ dispatch(setPolygon({ type: "Polygon", coordinates: null }));
+ }
+ };
+
+ map.on('draw.create', updateArea);
+ map.on('draw.update', updateArea);
+ map.on('draw.delete', updateArea);
+ }
+ });
+
+ // ResizeObserver to handle container size changes (e.g. inside Dialogs/Tabs)
+ const resizeObserver = new ResizeObserver(() => {
+ map.resize();
+ });
+ resizeObserver.observe(mapContainerRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ if (markerRef.current) {
+ markerRef.current.remove();
+ markerRef.current = null;
+ }
+ if (drawRef.current && mapRef.current) {
+ mapRef.current.removeControl(drawRef.current as unknown as mapboxgl.IControl);
+ drawRef.current = null;
+ }
+ if (mapRef.current) {
+ mapRef.current.remove();
+ mapRef.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mapMode]);
+
+ useEffect(() => {
+ if (mapMode !== 'marker') return;
+ const latNum = latitude?.trim() === '' ? NaN : Number(latitude);
+ const lngNum = longitude?.trim() === '' ? NaN : Number(longitude);
+ if (markerRef.current && mapRef.current && Number.isFinite(latNum) && Number.isFinite(lngNum)) {
+ markerRef.current.setLngLat([lngNum, latNum]);
+ mapRef.current.setCenter([lngNum, latNum]);
+ }
+ }, [latitude, longitude, mapMode]);
+
+ return (
+
+
+
+ {isGeocoding && (
+
+ Searching location...
+
+ )}
+
+ );
+}
+
+export default MiniMap;
diff --git a/src/vertex-template/components/features/network-status-banner/index.tsx b/src/vertex-template/components/features/network-status-banner/index.tsx
new file mode 100644
index 0000000000..8ff53d0736
--- /dev/null
+++ b/src/vertex-template/components/features/network-status-banner/index.tsx
@@ -0,0 +1,210 @@
+'use client';
+
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { AqWifiOff, AqSignal02, AqXClose } from '@airqo/icons-react';
+
+const DEBOUNCE_DELAY = 300;
+const AUTO_HIDE_DELAY = 5000;
+const API_DEGRADED_EVENT = 'vertex-network-degraded';
+const API_RECOVERED_EVENT = 'vertex-network-recovered';
+
+function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(t);
+ }, [value, delay]);
+ return debouncedValue;
+}
+
+function useMounted() {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ return mounted;
+}
+
+interface NetworkStatus {
+ isOnline: boolean;
+}
+
+interface ApiHealthStatus {
+ hasConnectivityIssue: boolean;
+}
+
+function useNetworkStatus(): NetworkStatus {
+ const initialOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
+ const [isOnline, setIsOnline] = useState(initialOnline);
+
+ useEffect(() => {
+ const handleOnline = () => setIsOnline(true);
+ const handleOffline = () => setIsOnline(false);
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ return { isOnline };
+}
+
+function useApiHealthStatus(): ApiHealthStatus {
+ const [hasConnectivityIssue, setHasConnectivityIssue] = useState(false);
+ const clearTimerRef = useRef | null>(null);
+
+ useEffect(() => {
+ const handleNetworkDegraded = () => {
+ setHasConnectivityIssue(true);
+ if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
+ clearTimerRef.current = setTimeout(() => setHasConnectivityIssue(false), 12000);
+ };
+
+ const handleNetworkRecovered = () => {
+ setHasConnectivityIssue(false);
+ if (clearTimerRef.current) {
+ clearTimeout(clearTimerRef.current);
+ clearTimerRef.current = null;
+ }
+ };
+
+ window.addEventListener(API_DEGRADED_EVENT, handleNetworkDegraded as EventListener);
+ window.addEventListener(API_RECOVERED_EVENT, handleNetworkRecovered as EventListener);
+
+ return () => {
+ if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
+ window.removeEventListener(API_DEGRADED_EVENT, handleNetworkDegraded as EventListener);
+ window.removeEventListener(API_RECOVERED_EVENT, handleNetworkRecovered as EventListener);
+ };
+ }, []);
+
+ return { hasConnectivityIssue };
+}
+
+interface NetworkStatusBannerProps {
+ position?: 'top' | 'bottom';
+ className?: string;
+ showDetailedInfo?: boolean;
+ autoHide?: boolean;
+ autoHideDelay?: number;
+}
+
+export default function NetworkStatusBanner({
+ position = 'top',
+ className = '',
+ showDetailedInfo = false,
+ autoHide = true,
+ autoHideDelay = AUTO_HIDE_DELAY,
+}: NetworkStatusBannerProps) {
+ const isMounted = useMounted();
+ const [dismissed, setDismissed] = useState(false);
+ const [showBackOnline, setShowBackOnline] = useState(false);
+ const { isOnline } = useNetworkStatus();
+ const { hasConnectivityIssue } = useApiHealthStatus();
+
+ const hasIssue = !isOnline || hasConnectivityIssue;
+ const shouldShow = useDebounce(hasIssue && !dismissed, DEBOUNCE_DELAY);
+
+ const config = useMemo(() => {
+ if (!isOnline) {
+ return {
+ message: 'You are offline. Check your internet connection.',
+ bgClass: 'bg-red-600',
+ Icon: AqWifiOff,
+ };
+ }
+ if (hasConnectivityIssue) {
+ return {
+ message: 'Connection is unstable. Some data may be temporarily unavailable.',
+ bgClass: 'bg-amber-600',
+ Icon: AqSignal02,
+ };
+ }
+ return null;
+ }, [isOnline, hasConnectivityIssue]);
+
+ const prevOnlineRef = useRef(isOnline);
+ useEffect(() => {
+ if (prevOnlineRef.current === false && isOnline === true) {
+ setShowBackOnline(true);
+ setDismissed(false);
+ }
+ prevOnlineRef.current = isOnline;
+ }, [isOnline]);
+
+ useEffect(() => {
+ if (!hasIssue) setDismissed(false);
+ }, [hasIssue]);
+
+ useEffect(() => {
+ if (!autoHide || !showBackOnline) return;
+ const t = setTimeout(() => setShowBackOnline(false), autoHideDelay);
+ return () => clearTimeout(t);
+ }, [autoHide, showBackOnline, autoHideDelay]);
+
+ const handleDismiss = useCallback(() => setDismissed(true), []);
+
+ if (!isMounted) return null;
+
+ if (showBackOnline && isOnline && !hasConnectivityIssue) {
+ return (
+
+
+
+
+ You're back online. Connection restored.
+
+
+
+ );
+ }
+
+ if (!shouldShow || !config) return null;
+
+ const positionClasses = position === 'top' ? 'top-4' : 'bottom-6';
+
+ return (
+
+
+
+
+
{config.message}
+ {showDetailedInfo && (
+
+ Network: {isOnline ? 'Online' : 'Offline'}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/networks/NetworkStatsCards.tsx b/src/vertex-template/components/features/networks/NetworkStatsCards.tsx
new file mode 100644
index 0000000000..4ececd7c3c
--- /dev/null
+++ b/src/vertex-template/components/features/networks/NetworkStatsCards.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useDeviceCount } from "@/core/hooks/useDevices";
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ AqMonitor,
+ AqCollocation,
+ AqWifiOff,
+ AqData,
+} from "@airqo/icons-react";
+import { useMemo } from "react";
+import { StatCard } from "@/components/features/dashboard/stat-card";
+import { getStatusExplanation } from "@/core/utils/status";
+
+interface NetworkStatsCardsProps {
+ networkId: string;
+ networkName: string;
+}
+
+export const NetworkStatsCards = ({ networkName }: NetworkStatsCardsProps) => {
+ // Pass network name to useDeviceCount for filtering
+ const deviceCountQuery = useDeviceCount({
+ enabled: !!networkName,
+ network: networkName,
+ });
+
+ const isLoading = deviceCountQuery.isLoading;
+
+ const metrics = useMemo(() => {
+ const summary = deviceCountQuery.data?.summary;
+ if (summary) {
+ return {
+ total: summary.total_monitors,
+ operational: summary.operational,
+ transmitting: summary.transmitting,
+ notTransmitting: summary.not_transmitting,
+ dataAvailable: summary.data_available,
+ };
+ }
+
+ return {
+ total: 0,
+ operational: 0,
+ transmitting: 0,
+ notTransmitting: 0,
+ dataAvailable: 0,
+ };
+ }, [deviceCountQuery.data]);
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentStatus = searchParams.get("status");
+
+ const handleFilter = (status?: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (status) {
+ params.set("status", status);
+ } else {
+ params.delete("status");
+ }
+ router.push(`?${params.toString()}`);
+ };
+
+ return (
+
+
+ }
+ description={`All devices assigned to ${networkName} network.`}
+ isLoading={isLoading}
+ onClick={() => handleFilter()}
+ variant="primary"
+ size="sm"
+ isActive={!currentStatus}
+ />
+
+ }
+ description={getStatusExplanation("Operational")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('operational')}
+ variant="success"
+ size="sm"
+ isActive={currentStatus === 'operational'}
+ />
+
+ }
+ description={getStatusExplanation("Transmitting")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('transmitting')}
+ variant="info"
+ size="sm"
+ isActive={currentStatus === 'transmitting'}
+ />
+
+ }
+ description={getStatusExplanation("Not Transmitting")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('not_transmitting')}
+ variant="default"
+ size="sm"
+ isActive={currentStatus === 'not_transmitting'}
+ />
+
+ }
+ description={getStatusExplanation("Data Available")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('data_available')}
+ variant="warning"
+ size="sm"
+ isActive={currentStatus === 'data_available'}
+ />
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/networks/client-paginated-networks-table.tsx b/src/vertex-template/components/features/networks/client-paginated-networks-table.tsx
new file mode 100644
index 0000000000..9039b4c241
--- /dev/null
+++ b/src/vertex-template/components/features/networks/client-paginated-networks-table.tsx
@@ -0,0 +1,144 @@
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import ReusableTable, {
+ TableColumn,
+ TableItem,
+} from "@/components/shared/table/ReusableTable";
+import { useRouter } from "next/navigation";
+import { Network } from "@/core/apis/networks";
+import { AqCopy01, AqLinkExternal01 } from "@airqo/icons-react";
+import { useBanner } from "@/context/banner-context";
+
+interface NetworksTableProps {
+ networks: Network[];
+ isLoading?: boolean;
+ error?: Error | null;
+ itemsPerPage?: number;
+ onNetworkClick?: (network: Network) => void;
+}
+
+type TableNetwork = TableItem;
+
+export default function ClientPaginatedNetworksTable({
+ networks,
+ isLoading = false,
+ error = null,
+ itemsPerPage = 10,
+ onNetworkClick,
+}: NetworksTableProps) {
+ const router = useRouter();
+ const { showBanner } = useBanner();
+
+ const handleNetworkClick = (item: unknown) => {
+ const network = item as Network;
+ if (onNetworkClick) onNetworkClick(network);
+ else if (network._id) router.push(`/admin/networks/${network._id}`);
+ };
+
+ const handleCopy = async (text: string, event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ showBanner({ message: "Sensor Manufacturer ID copied", severity: "success", scoped: false });
+ } catch {
+ showBanner({ message: "Failed to copy ID", severity: "error", scoped: false });
+ }
+ }
+ };
+
+ const networksWithId: TableNetwork[] = networks
+ .filter(
+ (n): n is Network & { _id: string } =>
+ typeof n._id === "string" && n._id.trim() !== ""
+ )
+ .map((n) => ({
+ ...n,
+ id: n._id,
+ }));
+
+ const columns: TableColumn[] = [
+ {
+ key: "net_name",
+ label: "Name",
+ render: (value) => {
+ const name = typeof value === "string" ? value : "";
+ return (
+
+ {name}
+
+ );
+ },
+ },
+ {
+ key: "_id",
+ label: "Sensor Manufacturer ID",
+ render: (value) => {
+ const id = typeof value === "string" ? value : "N/A";
+ if (id === "N/A") {
+ return N/A ;
+ }
+ return
+
{id}
+
handleCopy(id, e)}
+ className="text-gray-500 hover:text-primary p-1 rounded-md focus:outline-none"
+ aria-label="Copy Sensor Manufacturer ID"
+ >
+
+
+
;
+ },
+ },
+ {
+ key: "net_website",
+ label: "Website",
+ render: (value) => {
+ const url = String(value);
+ const isValidUrl = url.startsWith("http://") || url.startsWith("https://");
+
+ if (isValidUrl) {
+ return (
+ e.stopPropagation()}
+ >
+ {url}
+
+
+ );
+ }
+ return {url} ;
+ },
+ },
+ ];
+
+ return (
+
+
+
+ Unable to load networks
+ {error.message}
+
+ ) : (
+ "No sensor manufacturers available"
+ )
+ }
+ searchableColumns={["net_name"]}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/src/vertex-template/components/features/networks/create-network-form.tsx b/src/vertex-template/components/features/networks/create-network-form.tsx
new file mode 100644
index 0000000000..c0f8799d9b
--- /dev/null
+++ b/src/vertex-template/components/features/networks/create-network-form.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { Form, FormField } from "@/components/ui/form";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { AqPlus } from "@airqo/icons-react";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { networkFormSchema, NetworkFormValues } from "./schema";
+
+export function CreateNetworkForm() {
+ const [open, setOpen] = useState(false);
+ const [isPending, setIsPending] = useState(false);
+ const queryClient = useQueryClient();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const form = useForm({
+ resolver: zodResolver(networkFormSchema),
+ defaultValues: {
+ net_name: "",
+ net_acronym: "",
+ net_username: "",
+ net_email: "",
+ net_website: "",
+ net_phoneNumber: "",
+ net_category: "business",
+ net_description: "",
+ net_connection_endpoint: "",
+ net_connection_string: "",
+ },
+ });
+
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ form.reset();
+ }, [form]);
+
+ const onSubmit = useCallback(
+ async (values: NetworkFormValues) => {
+ setIsPending(true);
+ try {
+ await axios.post('/api/network', values, {
+ headers: { 'Content-Type': 'application/json' },
+ });
+ queryClient.invalidateQueries({ queryKey: ["networks"] });
+ handleClose();
+ showBannerWithDelay({ severity: 'success', message: 'Sensor Manufacturer created successfully!', scoped: false });
+ } catch (error: unknown) {
+ const errorMessage = getApiErrorMessage(error);
+ showBanner({ severity: 'error', message: errorMessage, scoped: true });
+ } finally {
+ setIsPending(false);
+ }
+ },
+ [handleClose, queryClient, showBanner, showBannerWithDelay]
+ );
+
+ return (
+ <>
+ setOpen(true)}
+ Icon={AqPlus}
+ >
+ Create Sensor Manufacturer
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/vertex-template/components/features/networks/network-detail-card.tsx b/src/vertex-template/components/features/networks/network-detail-card.tsx
new file mode 100644
index 0000000000..5431271f7c
--- /dev/null
+++ b/src/vertex-template/components/features/networks/network-detail-card.tsx
@@ -0,0 +1,37 @@
+import { Badge } from "@/components/ui/badge";
+
+interface NetworkDetailsCardProps {
+ name: string;
+ id: string;
+ loading?: boolean;
+}
+
+export default function NetworkDetailsCard({
+ name,
+ id,
+ loading = false,
+}: NetworkDetailsCardProps) {
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {name} Sensor Manufacturer
+
+
+
+ ID: {id}
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/networks/network-device-list-table.tsx b/src/vertex-template/components/features/networks/network-device-list-table.tsx
new file mode 100644
index 0000000000..c760b77a43
--- /dev/null
+++ b/src/vertex-template/components/features/networks/network-device-list-table.tsx
@@ -0,0 +1,205 @@
+
+import { Device } from "@/app/types/devices";
+import { useRouter, useSearchParams } from "next/navigation";
+import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable";
+import { useState, useMemo, useCallback } from "react";
+import { useNetworkDevices } from "@/core/hooks/useNetworks";
+import { getColumns, type TableDevice } from "@/components/features/devices/utils/table-columns";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices";
+import { UnassignCohortDevicesDialog } from "@/components/features/cohorts/unassign-cohort-devices";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { Edit, Plus, Trash2 } from "lucide-react";
+import BulkEditDevicesModal from "../devices/bulk-edit-device-details-modal";
+
+interface NetworkDevicesTableProps {
+ networkName: string;
+ networkId: string;
+ itemsPerPage?: number;
+ onDeviceClick?: (device: Device) => void;
+ className?: string;
+}
+
+export default function NetworkDevicesTable({
+ networkName,
+ networkId,
+ itemsPerPage = 25,
+ onDeviceClick,
+ className,
+}: NetworkDevicesTableProps) {
+ const router = useRouter();
+ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]);
+ const [showAssignDialog, setShowAssignDialog] = useState(false);
+ const [showUnassignDialog, setShowUnassignDialog] = useState(false);
+ const [showBulkEditModal, setShowBulkEditModal] = useState(false);
+ const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]);
+
+ const {
+ pagination,
+ setPagination,
+ searchTerm,
+ setSearchTerm,
+ sorting,
+ setSorting,
+ } = useServerSideTableState({ initialPageSize: itemsPerPage });
+
+ const searchParams = useSearchParams();
+ const status = searchParams.get("status");
+
+ const { devices, meta, isFetching, isLoading, error } = useNetworkDevices({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ network: networkName,
+ filterStatus: status || undefined,
+ });
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ const handleDeviceClick = (item: unknown) => {
+ const device = item as Device;
+ if (onDeviceClick) onDeviceClick(device);
+ else router.push(`/admin/networks/${networkId}/devices/${device._id}`);
+ };
+
+ const handleAssignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowAssignDialog(false);
+ };
+
+ const handleUnassignSuccess = () => {
+ setSelectedDeviceObjects([]);
+ setShowUnassignDialog(false);
+ };
+
+ const handleAddCohortDeviceActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowAssignDialog(true);
+ },
+ []
+ );
+
+ const handleUnassignActionSubmit = useCallback(
+ (selectedIds: (string | number)[]) => {
+ if (!selectedIds.length) return;
+ setShowUnassignDialog(true);
+ },
+ []
+ );
+
+ const handleBulkEditClose = () => {
+ setShowBulkEditModal(false);
+ setBulkEditDeviceIds([]);
+ };
+
+ const devicesWithId: TableDevice[] = useMemo(() => {
+ return devices
+ .filter(
+ (device: Device): device is Device & { _id: string } =>
+ typeof device._id === "string" && device._id.trim() !== ""
+ )
+ .map((device) => ({
+ ...device,
+ id: device._id,
+ }));
+ }, [devices]);
+
+ const columns = useMemo(() => getColumns(false), []);
+
+ const actions = useMemo(() => {
+ const baseActions: TableAction[] = [
+ {
+ label: "Add to Cohort",
+ value: "assign_cohort",
+ handler: handleAddCohortDeviceActionSubmit,
+ icon: Plus,
+ },
+ {
+ label: "Bulk Edit Devices",
+ value: "bulk_edit",
+ handler: (ids) => {
+ if (!ids.length) return;
+
+ const safeIds = ids.map(String);
+ setBulkEditDeviceIds(safeIds);
+ setShowBulkEditModal(true);
+ },
+ icon: Edit,
+ },
+ {
+ label: "Remove from Cohort",
+ value: "unassign_cohort",
+ handler: handleUnassignActionSubmit,
+ icon: Trash2,
+ }
+ ];
+
+ return baseActions;
+ }, [
+ handleAddCohortDeviceActionSubmit,
+ handleUnassignActionSubmit,
+ ]);
+
+ return (
+
+
setSelectedDeviceObjects(items as TableDevice[])}
+ actions={actions}
+ emptyState={
+ error ? (
+
+
+
Unable to load devices
+
+ {error.message || "An unknown error occurred"}
+
+
+ ) : (
+ "No devices available for this network"
+ )
+ }
+ serverSidePagination
+ pageCount={pageCount}
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ onSearchChange={setSearchTerm}
+ searchTerm={searchTerm}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ searchable
+ />
+
+ {/* Assign to Cohort Dialog */}
+
+
+ {/* Unassign from Cohort Dialog */}
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/networks/network-request-dialog.tsx b/src/vertex-template/components/features/networks/network-request-dialog.tsx
new file mode 100644
index 0000000000..39d7eb702c
--- /dev/null
+++ b/src/vertex-template/components/features/networks/network-request-dialog.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useCallback, useEffect } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Form, FormField } from "@/components/ui/form";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { networkRequestSchema, NetworkRequestValues } from "./schema";
+import { useAppSelector } from "@/core/redux/hooks";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+interface NetworkRequestDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function NetworkRequestDialog({ open, onOpenChange }: NetworkRequestDialogProps) {
+ const userDetails = useAppSelector((state) => state.user.userDetails);
+ const queryClient = useQueryClient();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+
+ const { mutate: submitRequest, isPending } = useMutation({
+ mutationFn: async (data: NetworkRequestValues) => {
+ const response = await fetch("/api/devices/network-creation-requests", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ message: "Request failed" }));
+ throw Object.assign(new Error("Request failed"), {
+ status: response.status,
+ data: errorData,
+ });
+ }
+ return response.json();
+ },
+ onSuccess: (resp) => {
+ queryClient.invalidateQueries({ queryKey: ["network-requests"] });
+ showBannerWithDelay({ severity: 'success', message: resp.message || "Your request for a new Sensor Manufacturer has been submitted successfully!", scoped: true });
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: getApiErrorMessage(error), scoped: true });
+ },
+ });
+
+ const form = useForm({
+ resolver: zodResolver(networkRequestSchema),
+ defaultValues: {
+ requester_name: "",
+ requester_email: "",
+ net_name: "",
+ net_email: "",
+ net_website: "",
+ net_category: "",
+ net_description: "",
+ net_acronym: "",
+ },
+ });
+
+ useEffect(() => {
+ if (open && userDetails) {
+ form.reset({
+ ...form.getValues(),
+ requester_name: [userDetails.firstName, userDetails.lastName]
+ .filter((part): part is string => Boolean(part && part.trim()))
+ .join(" "),
+ requester_email: userDetails.email || "",
+ });
+ }
+ }, [open, userDetails, form]);
+
+ const handleClose = useCallback(() => {
+ onOpenChange(false);
+ form.reset();
+ }, [form, onOpenChange]);
+
+ const onSubmit = (values: NetworkRequestValues) => {
+ const cleanedValues = Object.fromEntries(
+ Object.entries(values).filter(([, v]) => v !== "" && v !== null && v !== undefined)
+ ) as NetworkRequestValues;
+
+ submitRequest(cleanedValues, {
+ onSuccess: () => {
+ handleClose();
+ }
+ });
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/networks/request-table.tsx b/src/vertex-template/components/features/networks/request-table.tsx
new file mode 100644
index 0000000000..59408066ac
--- /dev/null
+++ b/src/vertex-template/components/features/networks/request-table.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import ReusableTable, {
+ TableColumn,
+} from "@/components/shared/table/ReusableTable";
+import { NetworkCreationRequest } from "@/core/apis/networks";
+import { Badge } from "@/components/ui/badge";
+import { Check, X, Eye, MoreHorizontal } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+interface RequestTableProps {
+ requests: NetworkCreationRequest[];
+ isLoading: boolean;
+ onAction: (request: NetworkCreationRequest, type: 'approve' | 'deny' | 'review') => void;
+}
+
+interface TableRequest extends NetworkCreationRequest {
+ id: string | number;
+ [key: string]: unknown;
+}
+
+export default function NetworkRequestTable({ requests, isLoading, onAction }: RequestTableProps) {
+ const columns: TableColumn[] = [
+ {
+ key: "net_name",
+ label: "Manufacturer",
+ render: (value, item) => (
+
+ {String(value)}
+ {item.net_acronym || 'No acronym'}
+
+ )
+ },
+ {
+ key: "requester_name",
+ label: "Requester",
+ render: (value, item) => (
+
+ {String(value)}
+ {item.requester_email}
+
+ )
+ },
+ {
+ key: "status",
+ label: "Status",
+ render: (value) => {
+ const status = String(value);
+ const variants: Record = {
+ pending: "bg-yellow-100 text-yellow-800 border-yellow-200",
+ under_review: "bg-blue-100 text-blue-800 border-blue-200",
+ approved: "bg-green-100 text-green-800 border-green-200",
+ denied: "bg-red-100 text-red-800 border-red-200",
+ };
+ return (
+
+ {status.replace("_", " ")}
+
+ );
+ }
+ },
+ {
+ key: "createdAt",
+ label: "Submitted",
+ render: (value) => new Date(String(value)).toLocaleDateString()
+ },
+ {
+ key: "actions",
+ label: "Actions",
+ render: (_, item) => {
+ const canAction = item.status === 'pending' || item.status === 'under_review';
+ return (
+
+
+
+
+
+
+
+ Actions
+
+ {canAction && (
+ <>
+ onAction(item, 'approve')} className="text-green-600">
+ Approve
+
+ onAction(item, 'deny')} className="text-red-600">
+ Deny
+
+ {item.status === 'pending' && (
+ onAction(item, 'review')}>
+ Mark Under Review
+
+ )}
+ >
+ )}
+ {!canAction && (
+ No actions available
+ )}
+
+
+ );
+ }
+ }
+ ];
+
+ const data: TableRequest[] = requests.map(r => ({ ...r, id: r._id }));
+
+ return (
+
+ );
+}
diff --git a/src/vertex-template/components/features/networks/schema.ts b/src/vertex-template/components/features/networks/schema.ts
new file mode 100644
index 0000000000..090cd29883
--- /dev/null
+++ b/src/vertex-template/components/features/networks/schema.ts
@@ -0,0 +1,31 @@
+import * as z from "zod";
+
+export const networkFormSchema = z.object({
+ net_name: z.string().min(2, "Sensor Manufacturer name must be at least 2 characters."),
+ net_acronym: z.string().min(2, "Acronym must be at least 2 characters."),
+ net_username: z.string().min(2, "Username must be at least 2 characters."),
+ net_email: z.string().email("Invalid email address."),
+ net_website: z.string().url("Invalid URL."),
+ net_phoneNumber: z.string().min(10, "Phone number seems too short."),
+ net_category: z.enum(["business", "research", "policy", "awareness", "school", "others"], {
+ errorMap: () => ({ message: "Please select a valid category" }),
+ }),
+ net_description: z.string().min(10, "Description is too short."),
+ net_connection_endpoint: z.string().url("Invalid URL."),
+ net_connection_string: z.string().url("Invalid URL."),
+});
+
+export type NetworkFormValues = z.infer;
+
+export const networkRequestSchema = z.object({
+ requester_name: z.string().min(2, "Requester name must be at least 2 characters."),
+ requester_email: z.string().email("Invalid email address."),
+ net_name: z.string().min(2, "Sensor Manufacturer name must be at least 2 characters."),
+ net_email: z.string().email("Invalid email address."),
+ net_website: z.string().url("Invalid URL.").optional().or(z.literal("")),
+ net_category: z.string().optional(),
+ net_description: z.string().optional(),
+ net_acronym: z.string().optional(),
+});
+
+export type NetworkRequestValues = z.infer;
diff --git a/src/vertex-template/components/features/org-picker/organization-modal.tsx b/src/vertex-template/components/features/org-picker/organization-modal.tsx
new file mode 100644
index 0000000000..19deb0b5a9
--- /dev/null
+++ b/src/vertex-template/components/features/org-picker/organization-modal.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import React, { useState, useMemo, useEffect } from "react";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import { Input } from "@/components/ui/input";
+import { AqSearchRefraction } from '@airqo/icons-react';
+import type { Group } from "@/app/types/users";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+interface OrganizationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ userGroups?: Group[];
+ activeGroup: Group | null;
+ onOrganizationChange: (group: Group) => void;
+}
+
+const formatTitle = (title: string) => {
+ if (!title) return "";
+ return title
+ .replace(/[_-]/g, " ")
+ .toUpperCase();
+};
+
+const OrganizationModal: React.FC = ({
+ isOpen,
+ onClose,
+ userGroups,
+ activeGroup,
+ onOrganizationChange,
+}) => {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [recentGroups, setRecentGroups] = useState([]);
+
+ useEffect(() => {
+ const storedRecents = localStorage.getItem("recentOrganizations");
+ let recentIds: string[] = storedRecents ? JSON.parse(storedRecents) : [];
+
+ const allUserGroups = userGroups || [];
+
+ if (activeGroup) {
+ recentIds = recentIds.filter(id => id !== activeGroup._id);
+ recentIds.unshift(activeGroup._id);
+ }
+
+ const groupMap = new Map(allUserGroups.map(g => [g._id, g]));
+
+ const recents = recentIds
+ .map(id => groupMap.get(id))
+ .filter((g): g is Group => g !== undefined);
+
+ setRecentGroups(recents);
+ }, [userGroups, isOpen, activeGroup]);
+
+ const updateRecents = (group: Group) => {
+ const updatedRecents = [group._id, ...recentGroups.map(g => g._id).filter(id => id !== group._id)].slice(0, 5);
+ localStorage.setItem("recentOrganizations", JSON.stringify(updatedRecents));
+ }
+
+ const handleSelection = (group: Group) => {
+ updateRecents(group);
+ onOrganizationChange(group);
+ }
+
+ const filteredGroups = useMemo(() => {
+ if (!Array.isArray(userGroups)) {
+ return [];
+ }
+
+ const groups = userGroups.filter((group) =>
+ group.grp_title &&
+ group.grp_title.toLowerCase().includes(searchTerm?.toLowerCase() || '')
+ );
+
+ return groups;
+ }, [userGroups, searchTerm]);
+
+ const handleCreateNew = () => {
+ const baseUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL || 'https://analytics.airqo.net';
+ const url = `${baseUrl.replace(/\/$/, '')}/request-organization`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ onClose();
+ }
+
+
+ const OrganizationItem = ({ group }: { group: Group }) => {
+ const isActive = activeGroup?._id === group._id;
+
+ return (
+ handleSelection(group)}
+ className={`flex items-center justify-between p-2 hover:bg-accent dark:hover:bg-zinc-800 rounded-lg cursor-pointer transition-colors duration-200 ${isActive && "border border-primary/30 bg-primary/5 dark:bg-primary/20 dark:border-primary/50"}`}
+ >
+
+
+ {group.grp_title.charAt(0)}
+
+
+
{formatTitle(group.grp_title)}
+
+
+ {isActive &&
}
+
+ );
+ };
+
+ const renderOrganizationList = (groups: Group[]) => {
+ return (
+
+ {groups.length > 0 ? (
+ groups.map((group) =>
)
+ ) : (
+
+ No organizations found.
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+ Close
+
+
+ Request New Organization
+
+
+ }
+ >
+
+
+
+
setSearchTerm(e.target.value)}
+ />
+
+
+
+ {renderOrganizationList(filteredGroups)}
+
+
+ );
+};
+
+export default OrganizationModal;
\ No newline at end of file
diff --git a/src/vertex-template/components/features/org-picker/organization-picker.tsx b/src/vertex-template/components/features/org-picker/organization-picker.tsx
new file mode 100644
index 0000000000..e12921a802
--- /dev/null
+++ b/src/vertex-template/components/features/org-picker/organization-picker.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import React, { useState, useMemo, useEffect, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { useAppSelector, useAppDispatch } from "@/core/redux/hooks";
+import {
+ clearForbiddenState,
+ setActiveGroup,
+ setUserContext,
+ setOrganizationSwitching,
+} from "@/core/redux/slices/userSlice";
+import type { Group } from "@/app/types/users";
+import OrganizationModal from "./organization-modal";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { UserContext } from "@/core/redux/slices/userSlice";
+import { AqGrid01 } from "@airqo/icons-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRouter, usePathname } from "next/navigation";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+
+export const formatTitle = (title: string) => {
+ if (!title) return "";
+ return title.replace(/[_-]/g, " ").toUpperCase();
+};
+
+const OrganizationPicker: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+ const userGroups = useAppSelector((state) => state.user.userGroups);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { isLoading } = useUserContext();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { isSwitching } = useAppSelector((state) => state.user.organizationSwitching);
+ const pathname = usePathname();
+ const lastPathname = useRef(pathname);
+
+ // Clear isSwitching when navigation completes (pathname changes)
+ useEffect(() => {
+ if (isSwitching && pathname !== lastPathname.current) {
+ dispatch(setOrganizationSwitching({ isSwitching: false, switchingTo: "" }));
+ }
+ lastPathname.current = pathname;
+ }, [pathname, isSwitching, dispatch]);
+
+ const validUserGroups = useMemo(() => {
+ if (!Array.isArray(userGroups)) return [];
+
+ return userGroups
+ .filter((group): group is Group => !!(group && group._id && group.grp_title))
+ .sort((a, b) => a.grp_title.localeCompare(b.grp_title));
+ }, [userGroups]);
+
+ const handleOrganizationChange = async (group: Group) => {
+ setIsModalOpen(false);
+ dispatch(clearForbiddenState());
+
+ const newContext: UserContext =
+ group.grp_title.toLowerCase() === "airqo"
+ ? "personal"
+ : "external-org";
+
+ try {
+ // Optimized Background Cleanup (Non-blocking)
+ const orgScopedQueryKeys = [
+ ["devices"],
+ ["myDevices"],
+ ["sites"],
+ ["site-details"],
+ ["cohorts"],
+ ["user-cohorts"],
+ ["cohort-details"],
+ ["claimedDevices"],
+ ["deviceActivities"],
+ ["deviceCount"],
+ ["network-devices"],
+ ["groupCohorts"],
+ ["network-requests"],
+ ] as const;
+
+ orgScopedQueryKeys.forEach((queryKey) => {
+ void queryClient.cancelQueries({ queryKey });
+ queryClient.removeQueries({ queryKey });
+ });
+
+ // Start shimmer just before navigation fires
+ dispatch(setOrganizationSwitching({ isSwitching: true, switchingTo: group.grp_title }));
+
+ dispatch(setActiveGroup(group));
+ dispatch(setUserContext(newContext));
+
+ if (pathname === "/home") {
+ dispatch(setOrganizationSwitching({ isSwitching: false, switchingTo: "" }));
+ router.refresh();
+ } else {
+ router.push("/home");
+ }
+ } catch {
+ showBannerWithDelay({
+ severity: 'error',
+ message: 'Failed to switch organization. Please try again.',
+ scoped: false,
+ });
+ }
+ };
+
+ const getDisplayTitle = () => {
+ return formatTitle(activeGroup?.grp_title || "") || "Select Organization";
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+ <>
+ setIsModalOpen(true)}
+ title="My Organizations"
+ aria-label="My Organizations"
+ >
+
+ {getDisplayTitle()?.charAt(0)?.toUpperCase() || "O"}
+
+ {getDisplayTitle()}
+
+
+
+ setIsModalOpen(false)}
+ userGroups={validUserGroups}
+ activeGroup={activeGroup}
+ onOrganizationChange={handleOrganizationChange}
+ />
+ >
+ );
+};
+
+export default OrganizationPicker;
diff --git a/src/vertex-template/components/features/shipping/PrepareShippingModal.tsx b/src/vertex-template/components/features/shipping/PrepareShippingModal.tsx
new file mode 100644
index 0000000000..6914c50ee4
--- /dev/null
+++ b/src/vertex-template/components/features/shipping/PrepareShippingModal.tsx
@@ -0,0 +1,314 @@
+"use client";
+
+import React, { useState, useRef } from 'react';
+import { usePrepareBulkDevicesForShipping } from '@/core/hooks/useDevices';
+import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import { AqPlus, AqXClose, AqUploadCloud02 } from '@airqo/icons-react';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import { useBanner } from '@/context/banner-context';
+import { useBannerWithDelay } from '@/core/hooks/useBannerWithDelay';
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
+
+interface PrepareShippingModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const PrepareShippingModal: React.FC = ({ isOpen, onClose }) => {
+ const [devices, setDevices] = useState([]);
+ const [batchName, setBatchName] = useState('');
+ const [currentInput, setCurrentInput] = useState('');
+ const [tokenType, setTokenType] = useState<'hex' | 'readable'>('readable');
+ const [isImporting, setIsImporting] = useState(false);
+ const [filePreview, setFilePreview] = useState<{ headers: string[], data: unknown[][], fileName: string } | null>(null);
+ const [selectedColumn, setSelectedColumn] = useState(0);
+ const fileInputRef = useRef(null);
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { mutate: prepareBulk, isPending } = usePrepareBulkDevicesForShipping({
+ onSuccess: (data) => {
+ showBannerWithDelay({ severity: 'success', message: data.message, scoped: false });
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Bulk Preparation Failed: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
+
+ const handleAddDevice = () => {
+ const trimmedInput = currentInput.trim();
+ if (!trimmedInput) return;
+ if (devices.includes(trimmedInput)) {
+ showBanner({ severity: 'error', message: 'Device already added', scoped: true });
+ return;
+ }
+ setDevices([...devices, trimmedInput]);
+ setCurrentInput('');
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddDevice();
+ }
+ };
+
+ const handleRemoveDevice = (deviceToRemove: string) => {
+ setDevices(devices.filter(device => device !== deviceToRemove));
+ };
+
+ const handleClearAll = () => {
+ setDevices([]);
+ };
+
+ const handleFileImport = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
+ if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) {
+ showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true });
+ e.target.value = '';
+ return;
+ }
+
+ if (file.size > 5 * 1024 * 1024) {
+ showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true });
+ e.target.value = '';
+ return;
+ }
+
+ setIsImporting(true);
+
+ try {
+ let parsedData: unknown[][] = [];
+ let headers: string[] = [];
+
+ if (fileExtension === 'csv') {
+ const Papa = (await import('papaparse')).default;
+ Papa.parse(file, {
+ complete: (results) => {
+ parsedData = results.data as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ setSelectedColumn(0);
+ }
+ setIsImporting(false);
+ },
+ error: (error) => {
+ showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true });
+ setIsImporting(false);
+ },
+ });
+ } else {
+ const XLSX = await import('xlsx');
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ const bstr = evt.target?.result;
+ const workbook = XLSX.read(bstr, { type: 'binary' });
+ const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
+ parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][];
+ if (parsedData.length > 0) {
+ const firstRow = parsedData[0];
+ headers = (firstRow as unknown[]).map((cell: unknown, index: number) => {
+ const cellStr = String(cell || '').trim();
+ return cellStr || `Column ${index + 1}`;
+ });
+ setFilePreview({ headers, data: parsedData, fileName: file.name });
+ setSelectedColumn(0);
+ }
+ setIsImporting(false);
+ };
+ reader.onerror = () => {
+ showBanner({ severity: 'error', message: 'Error reading Excel file', scoped: true });
+ setIsImporting(false);
+ };
+ reader.readAsBinaryString(file);
+ }
+ } catch {
+ showBanner({ severity: 'error', message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', scoped: true });
+ setIsImporting(false);
+ }
+
+ e.target.value = '';
+ };
+
+ const handleConfirmImport = () => {
+ if (!filePreview) return;
+ const importedDevices = filePreview.data.slice(1).map(row => {
+ const value = row[selectedColumn];
+ return typeof value === 'string' ? value.trim() : String(value || '').trim();
+ }).filter((name: string) => name.length > 0);
+
+ const uniqueImportedDevices = Array.from(new Set(importedDevices));
+ processImportedDevices(uniqueImportedDevices);
+ setFilePreview(null);
+ };
+
+ const handleCancelImport = () => {
+ setFilePreview(null);
+ setSelectedColumn(0);
+ };
+
+ const processImportedDevices = (importedDevices: string[]) => {
+ if (importedDevices.length === 0) {
+ showBanner({ severity: 'error', message: 'No valid device names found in the selected column', scoped: true });
+ return;
+ }
+
+ const newDevices = importedDevices.filter(device => !devices.includes(device));
+ const duplicateCount = importedDevices.length - newDevices.length;
+
+ if (newDevices.length > 0) {
+ setDevices([...devices, ...newDevices]);
+ let message = `Imported ${newDevices.length} device${newDevices.length !== 1 ? 's' : ''}`;
+ if (duplicateCount > 0) {
+ message += ` (${duplicateCount} duplicate${duplicateCount !== 1 ? 's' : ''} skipped)`;
+ }
+ showBanner({ severity: 'success', message, scoped: true });
+ } else if (duplicateCount > 0) {
+ showBanner({ severity: 'info', message: 'All devices in the file are already added', scoped: true });
+ }
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (devices.length === 0) {
+ showBanner({ severity: 'error', message: 'Please add at least one device', scoped: true });
+ return;
+ }
+ prepareBulk(
+ { deviceNames: devices, tokenType, batchName: batchName.trim() || undefined },
+ {
+ onSuccess: () => {
+ // Reset form state
+ setDevices([]);
+ setCurrentInput('');
+ setTokenType('readable');
+ setBatchName('');
+ // Close modal
+ onClose();
+ }
+ }
+ );
+ };
+
+ return (
+
+
+ {filePreview && (
+
+
+
+
Select Device Name Column
+
File: {filePreview.fileName}
+
+
+
Please select which column contains the device names:
+
+
Showing first 5 rows as preview
+
+
+ Cancel
+ Import Device Names
+
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/shipping/ShippingBatchesTable.tsx b/src/vertex-template/components/features/shipping/ShippingBatchesTable.tsx
new file mode 100644
index 0000000000..0f114625a9
--- /dev/null
+++ b/src/vertex-template/components/features/shipping/ShippingBatchesTable.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { useRouter } from 'next/navigation';
+import { useShippingBatches } from '@/core/hooks/useDevices';
+import { format } from 'date-fns';
+import { ShippingBatch } from '@/app/types/devices';
+import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable';
+
+type BatchTableItem = ShippingBatch & {
+ id: string;
+ [key: string]: unknown;
+};
+
+const ShippingBatchesTable: React.FC = () => {
+ const router = useRouter();
+ const { data, isLoading, error } = useShippingBatches();
+
+ const columns: TableColumn[] = [
+ {
+ key: 'batch_name',
+ label: 'Batch Name',
+ render: (value) => (
+
+ {value as string || 'Unnamed Batch'}
+
+ )
+ },
+ {
+ key: 'device_count',
+ label: 'Device Count',
+ render: (value) => (
+
+ {value as number} devices
+
+ )
+ },
+ {
+ key: 'createdAt',
+ label: 'Created Date',
+ render: (value) => (
+
+ {format(new Date(value as string), 'MMM dd, yyyy HH:mm')}
+
+ )
+ },
+ {
+ key: 'actions',
+ label: 'Actions',
+ render: (_, item) => (
+ {
+ e.stopPropagation();
+ router.push(`/admin/shipping/${item._id}`);
+ }}
+ >
+ View Details
+
+ )
+ }
+ ];
+
+ if (error) {
+ return (
+
+ Error loading batches: {error.message}
+
+ );
+ }
+
+ const batches: BatchTableItem[] = (data?.batches || []).map(batch => ({
+ ...batch,
+ id: batch._id
+ }));
+
+ return (
+
+ No shipping batches found. Create a new batch by preparing devices for shipping.
+
+ }
+ onRowClick={(batch) => router.push(`/admin/shipping/${batch._id}`)}
+ searchableColumns={['batch_name']}
+ />
+ );
+};
+
+export default ShippingBatchesTable;
diff --git a/src/vertex-template/components/features/shipping/ShippingLabelPrint.tsx b/src/vertex-template/components/features/shipping/ShippingLabelPrint.tsx
new file mode 100644
index 0000000000..d788a28b42
--- /dev/null
+++ b/src/vertex-template/components/features/shipping/ShippingLabelPrint.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { ShippingLabel } from '@/app/types/devices';
+import ReusableButton from '@/components/shared/button/ReusableButton';
+import Image from 'next/image';
+
+interface ShippingLabelPrintProps {
+ labels: ShippingLabel[];
+}
+
+const ShippingLabelPrint: React.FC = ({ labels }) => {
+ const printLabels = () => {
+ window.print();
+ };
+
+ return (
+
+
+
+ Print Labels
+
+
+
+ {labels.map((label, index) => (
+
+
+
AirQo Air Quality Monitor
+
+
+
+
Device ID: {label.device_id}
+
Claim Token: {label.claim_token}
+
+
+
+
+
+
+
+
Setup Instructions:
+
+ {label.instructions.map((instruction, i) => (
+ {i + 1}. {instruction}
+ ))}
+
+
+
+ ))}
+
+ );
+};
+
+export default ShippingLabelPrint;
diff --git a/src/vertex-template/components/features/shipping/ShippingLabelPrintModal.tsx b/src/vertex-template/components/features/shipping/ShippingLabelPrintModal.tsx
new file mode 100644
index 0000000000..03be11b52c
--- /dev/null
+++ b/src/vertex-template/components/features/shipping/ShippingLabelPrintModal.tsx
@@ -0,0 +1,319 @@
+"use client";
+
+import React from 'react';
+import { ShippingLabel } from '@/app/types/devices';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import { Printer } from 'lucide-react';
+import Image from 'next/image';
+
+interface ShippingLabelPrintModalProps {
+ labels: ShippingLabel[];
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const ShippingLabelPrintModal: React.FC = ({ labels, isOpen, onClose }) => {
+ const isValidDataUrl = (url: string) => {
+ return url.startsWith('data:image/') || /^https?:\/\//i.test(url);
+ };
+
+ const handlePrint = () => {
+ // Generate HTML for the new window
+ const printWindow = window.open('', '_blank');
+
+ if (!printWindow) {
+ alert('Please allow pop-ups for this site to print labels');
+ return;
+ }
+
+ const invalidLabels = labels.filter(label => !isValidDataUrl(label.qr_code_image));
+ if (invalidLabels.length > 0) {
+ alert('Some QR code images have invalid URLs');
+ return;
+ }
+
+ const escapeHtml = (str: string) =>
+ str.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ // Build the complete HTML document for the print window
+ const htmlContent = `
+
+
+
+
+
+ Shipping Labels - AirQo
+
+
+
+
+ ${labels.map(label => `
+
+
+
+
Device ID: ${escapeHtml(label.device_id)}
+
Claim Token: ${escapeHtml(label.claim_token)}
+
+
+
+
+
+
Setup Instructions:
+
+ ${label.instructions.map(instruction => `${escapeHtml(instruction)} `).join('')}
+
+
+
+ `).join('')}
+
+
+
+ `;
+
+ // Write content to the new window
+ printWindow.document.write(htmlContent);
+ printWindow.document.close();
+
+ // Wait for images to load, then trigger print dialog
+ printWindow.onload = () => {
+ setTimeout(() => {
+ printWindow.print();
+ }, 250);
+ };
+ };
+
+ return (
+
+
+ {labels.map((label, index) => (
+
+
+
AirQo Air Quality Monitor
+
+
+
+
Device ID: {label.device_id}
+
Claim Token: {label.claim_token}
+
+
+
+
+
+
+
+
Setup Instructions:
+
+ {label.instructions.map((instruction, i) => (
+ {instruction}
+ ))}
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default ShippingLabelPrintModal;
diff --git a/src/vertex-template/components/features/sites/client-paginated-sites-table.tsx b/src/vertex-template/components/features/sites/client-paginated-sites-table.tsx
new file mode 100644
index 0000000000..04d6baee1e
--- /dev/null
+++ b/src/vertex-template/components/features/sites/client-paginated-sites-table.tsx
@@ -0,0 +1,170 @@
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import ReusableTable, {
+ TableColumn,
+ TableItem,
+} from "@/components/shared/table/ReusableTable";
+import { useRouter } from "next/navigation";
+import {
+ badgeColorClasses,
+ formatDisplayDate,
+ getDeviceStatus,
+} from "@/core/utils/status";
+import { Site } from "@/app/types/sites";
+
+interface SitesTableProps {
+ sites: Site[];
+ isLoading?: boolean;
+ error?: Error | null;
+ itemsPerPage?: number;
+ onSiteClick?: (site: Site) => void;
+ multiSelect?: boolean;
+ onSelectedSitesChange?: (selectedSites: Site[]) => void;
+}
+
+type TableSite = TableItem;
+
+export default function ClientPaginatedSitesTable({
+ sites,
+ isLoading = false,
+ error = null,
+ itemsPerPage = 10,
+ onSiteClick,
+ multiSelect = false,
+ onSelectedSitesChange,
+}: SitesTableProps) {
+ const router = useRouter();
+
+ const handleSiteClick = (item: unknown) => {
+ const site = item as Site;
+ if (onSiteClick) onSiteClick(site);
+ else if (site._id) router.push(`/admin/sites/${site._id}`);
+ };
+
+ const handleSelectedItemsChange = (selectedIds: (string | number)[]) => {
+ if (onSelectedSitesChange) {
+ const selected = sites.filter((s) => s._id && selectedIds.includes(s._id));
+ onSelectedSitesChange(selected);
+ }
+ };
+
+ const sitesWithId: TableSite[] = sites
+ .filter(
+ (s): s is Site & { _id: string } => typeof s._id === "string" && s._id.trim() !== ""
+ )
+ .map((s) => ({
+ ...s,
+ id: s._id,
+ }));
+
+ const columns: TableColumn[] = [
+ {
+ key: "name",
+ label: "Name",
+ render: (value) => {
+ const name = typeof value === "string" ? value : "";
+ return {name} ;
+ },
+ },
+ {
+ key: "generated_name",
+ label: "Site ID",
+ render: (value) => {
+ const id = typeof value === "string" ? value : "";
+ return {id} ;
+ },
+ },
+ {
+ key: "description",
+ label: "Description",
+ render: (value, item) => {
+ const site = item as unknown as Site;
+ const desc = site.description || site.formatted_name || site.location_name || "";
+ return (
+
+ {desc}
+
+ );
+ },
+ },
+ {
+ key: "country",
+ label: "Country",
+ render: (value) => {
+ const country = typeof value === "string" ? value : "";
+ return {country} ;
+ },
+ },
+ {
+ key: "district",
+ label: "District",
+ render: (value) => {
+ const district = typeof value === "string" ? value : "";
+ return {district} ;
+ },
+ },
+ {
+ key: "isOnline",
+ label: "Status",
+ render: (isOnline, item) => {
+ const site = item as Site;
+ const lastActiveCheck = site.lastActive
+ ? formatDisplayDate(site.lastActive)
+ : null;
+
+ const status = getDeviceStatus(
+ site.isOnline || false,
+ site.rawOnlineStatus,
+ lastActiveCheck
+ );
+ const colors = badgeColorClasses[status.color];
+ const Icon = status.icon;
+
+ return (
+
+
+ {status.label}
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+
+
+ Unable to load sites
+ {error.message}
+
+ ) : (
+ "No sites available"
+ )
+ }
+ searchableColumns={[
+ "name",
+ "location_name",
+ "generated_name",
+ "formatted_name",
+ "description",
+ "country",
+ "district",
+ ]}
+ />
+
+ );
+}
diff --git a/src/vertex-template/components/features/sites/create-site-form.tsx b/src/vertex-template/components/features/sites/create-site-form.tsx
new file mode 100644
index 0000000000..d57f4c2938
--- /dev/null
+++ b/src/vertex-template/components/features/sites/create-site-form.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { useState, useCallback, Suspense } from "react";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import dynamic from "next/dynamic";
+import { Form, FormField } from "@/components/ui/form";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { useAppSelector } from "@/core/redux/hooks";
+import "leaflet/dist/leaflet.css";
+import { useApproximateCoordinates, useCreateSite } from "@/core/hooks/useSites";
+import { AqPlus } from "@airqo/icons-react";
+import LocationAutocomplete from "@/components/features/location-autocomplete/LocationAutocomplete";
+import { Label } from "@/components/ui/label";
+import { useNetworks } from "@/core/hooks/useNetworks";
+import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
+
+// Lazy load MiniMap to reduce initial bundle size
+const MiniMap = dynamic(() => import("@/components/features/mini-map/mini-map"), {
+ ssr: false,
+ loading: () => (
+
+ Loading map...
+
+ ),
+});
+
+const siteFormSchema = z.object({
+ name: z.string().min(2, {
+ message: "Site name must be at least 2 characters.",
+ }),
+ network: z.string().min(1, {
+ message: "Please select a network.",
+ }),
+ latitude: z.string().refine(
+ (val) => {
+ const num = parseFloat(val);
+ return !isNaN(num) && num >= -90 && num <= 90;
+ },
+ {
+ message: "Latitude must be a valid number between -90 and 90",
+ }
+ ),
+ longitude: z.string().refine(
+ (val) => {
+ const num = parseFloat(val);
+ return !isNaN(num) && num >= -180 && num <= 180;
+ },
+ {
+ message: "Longitude must be a valid number between -180 and 180",
+ }
+ ),
+});
+
+type SiteFormValues = z.infer;
+
+interface CreateSiteFormProps {
+ disabled?: boolean;
+ basePath?: string;
+}
+
+export function CreateSiteForm({ disabled = false, basePath = "/admin/sites" }: CreateSiteFormProps) {
+ const [open, setOpen] = useState(false);
+ const router = useRouter();
+ const [inputMode, setInputMode] = useState<"siteName" | "coordinates">("coordinates");
+ const activeGroup = useAppSelector((state) => state.user.activeGroup);
+ const { getApproximateCoordinates, isPending: isOptimizing } = useApproximateCoordinates();
+ const { mutate: createSite, isPending: isCreating } = useCreateSite();
+ const { networks, isLoading: isLoadingNetworks } = useNetworks();
+
+ const form = useForm({
+ resolver: zodResolver(siteFormSchema),
+ defaultValues: {
+ name: "",
+ network: "",
+ latitude: "",
+ longitude: "",
+ },
+ });
+
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ form.reset();
+ setInputMode("siteName");
+ }, [form]);
+
+ const onSubmit = useCallback((values: SiteFormValues) => {
+ createSite(
+ {
+ ...values,
+ network: values.network,
+ group: activeGroup?.grp_title || "",
+ },
+ {
+ onSuccess: (data) => {
+ handleClose();
+ if (data?.site?._id) {
+ router.push(`${basePath}/${data.site._id}`);
+ }
+ },
+ }
+ );
+ }, [createSite, activeGroup?.grp_title, handleClose, router, basePath]);
+
+ const handleCoordinateChange = useCallback((lat: string, lng: string) => {
+ form.setValue("latitude", lat, { shouldValidate: true });
+ form.setValue("longitude", lng, { shouldValidate: true });
+ }, [form]);
+
+ const handleSiteNameChange = useCallback((name: string) => {
+ form.setValue("name", name, { shouldValidate: true });
+ }, [form]);
+
+ const handleOptimizeCoordinates = useCallback(() => {
+ const lat = form.getValues("latitude");
+ const lng = form.getValues("longitude");
+ if (lat && lng) {
+ getApproximateCoordinates(
+ { latitude: lat, longitude: lng },
+ {
+ onSuccess: (data) => {
+ const { approximate_latitude, approximate_longitude } = data.approximate_coordinates;
+ form.setValue("latitude", approximate_latitude.toString());
+ form.setValue("longitude", approximate_longitude.toString());
+ },
+ }
+ );
+ }
+ }, [form, getApproximateCoordinates]);
+
+ const toggleInputMode = useCallback(() => {
+ setInputMode((prev) => (prev === "siteName" ? "coordinates" : "siteName"));
+ }, []);
+
+ return (
+ <>
+ setOpen(true)} Icon={AqPlus}>
+ Add Site
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/vertex-template/components/features/sites/edit-site-details-dialog.tsx b/src/vertex-template/components/features/sites/edit-site-details-dialog.tsx
new file mode 100644
index 0000000000..9bbfa58f43
--- /dev/null
+++ b/src/vertex-template/components/features/sites/edit-site-details-dialog.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { Form, FormField } from "@/components/ui/form";
+import { useUpdateSiteDetails } from "@/core/hooks/useSites";
+import { toast } from "sonner";
+import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
+import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { useEffect } from "react";
+import { Site } from "@/app/types/sites";
+
+const siteFormSchema = z.object({
+ name: z.string().min(2, { message: "Name must be at least 2 characters." }),
+ description: z.string().optional(),
+ latitude: z.string().refine((val) => {
+ const n = Number(val);
+ return Number.isFinite(n) && n >= -90 && n <= 90;
+ }, { message: "Latitude must be between -90 and 90." }),
+ longitude: z.string().refine((val) => {
+ const n = Number(val);
+ return Number.isFinite(n) && n >= -180 && n <= 180;
+ }, { message: "Longitude must be between -180 and 180." }),
+ parish: z.string().optional(),
+ subCounty: z.string().optional(),
+ district: z.string().optional(),
+ region: z.string().optional(),
+ altitude: z.string().optional().refine((val) => {
+ if (val === undefined || val === "") return true;
+ return Number.isFinite(Number(val));
+ }, { message: "Please enter a valid altitude" }),
+ search_name: z.string().optional(),
+ location_name: z.string().optional(),
+});
+
+type SiteFormValues = z.infer;
+
+interface EditSiteDetailsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ site: Site;
+ section: "general" | "mobile";
+}
+
+export function EditSiteDetailsDialog({
+ open,
+ onOpenChange,
+ site,
+ section,
+}: EditSiteDetailsDialogProps) {
+ const { mutate: updateSite, isPending } = useUpdateSiteDetails();
+
+ const form = useForm({
+ resolver: zodResolver(siteFormSchema),
+ });
+
+ useEffect(() => {
+ if (site && open) {
+ form.reset({
+ name: site.name || "",
+ description: site.description || "",
+ latitude: site.latitude?.toString() || "",
+ longitude: site.longitude?.toString() || "",
+ parish: site.parish || "",
+ subCounty: site.sub_county || "",
+ district: site.district || "",
+ region: site.region || "",
+ altitude: site.altitude?.toString() || "",
+ search_name: site.search_name || "",
+ location_name: site.location_name || "",
+ });
+ }
+ }, [site, open, form]);
+
+ function onSubmit(values: SiteFormValues) {
+ const dirtyFields = form.formState.dirtyFields;
+
+ const fieldMapping = {
+ name: "name",
+ description: "description",
+ latitude: "latitude",
+ longitude: "longitude",
+ parish: "parish",
+ subCounty: "sub_county",
+ district: "district",
+ region: "region",
+ altitude: "altitude",
+ search_name: "search_name",
+ location_name: "location_name",
+ };
+
+ const numericFields = new Set(["latitude", "longitude", "altitude"]);
+ const transformedData = Object.entries(fieldMapping).reduce(
+ (acc, [formField, apiField]) => {
+ if (dirtyFields[formField as keyof typeof dirtyFields]) {
+ const v = values[formField as keyof SiteFormValues];
+ acc[apiField] = numericFields.has(formField)
+ ? v
+ ? Number(v as string)
+ : undefined
+ : (v as string | undefined);
+ }
+ return acc;
+ },
+ {} as Record
+ );
+
+ if (Object.keys(transformedData).length === 0) {
+ toast.error("No fields have been modified");
+ return;
+ }
+
+ if (!site._id) {
+ toast.error("Site ID is missing");
+ return;
+ }
+
+ updateSite(
+ { siteId: site._id, data: transformedData },
+ {
+ onSuccess: () => {
+ onOpenChange(false);
+ },
+ }
+ );
+ }
+
+ return (
+ onOpenChange(false)}
+ title={section === "general" ? "Edit Site Details" : "Edit Mobile App Details"}
+ size="3xl"
+ primaryAction={{
+ label: isPending ? "Saving..." : "Save Changes",
+ onClick: form.handleSubmit(onSubmit),
+ disabled: isPending,
+ }}
+ secondaryAction={{
+ label: "Cancel",
+ onClick: () => onOpenChange(false),
+ variant: "outline",
+ disabled: isPending,
+ }}
+ >
+
+
+
+ );
+}
diff --git a/src/vertex-template/components/features/sites/site-activity-card.tsx b/src/vertex-template/components/features/sites/site-activity-card.tsx
new file mode 100644
index 0000000000..8b580dd61c
--- /dev/null
+++ b/src/vertex-template/components/features/sites/site-activity-card.tsx
@@ -0,0 +1,88 @@
+import { Card } from "@/components/ui/card";
+import React, { useEffect, useRef } from "react";
+import { useSiteActivitiesInfinite } from "@/core/hooks/useSites";
+import DeviceActivityItem from "@/components/features/devices/device-activity-item";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Loader2 } from "lucide-react";
+
+interface SiteActivityCardProps {
+ siteId: string;
+}
+
+const SiteActivityCard: React.FC = ({
+ siteId,
+}) => {
+ const {
+ data,
+ isLoading,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useSiteActivitiesInfinite(siteId);
+
+ const observerTarget = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextPage) {
+ fetchNextPage();
+ }
+ },
+ { threshold: 1.0 }
+ );
+
+ const currentTarget = observerTarget.current;
+ if (currentTarget) {
+ observer.observe(currentTarget);
+ }
+
+ return () => {
+ if (currentTarget) {
+ observer.unobserve(currentTarget);
+ }
+ };
+ }, [hasNextPage, fetchNextPage]);
+
+ const activities = data?.pages.flatMap((page) => page.site_activities) || [];
+
+ return (
+
+
+
Site Activity
+
+
+
+ {isLoading ? (
+
Loading history...
+ ) : error ? (
+
Failed to load history.
+ ) : activities.length === 0 ? (
+
No recent activity.
+ ) : (
+
+
+ {activities.map((activity, index) => (
+
+ ))}
+ {/* Loading spinner for infinite scroll */}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+
+ )}
+
+
+ );
+};
+
+export default SiteActivityCard;
diff --git a/src/vertex-template/components/features/sites/site-information-card.tsx b/src/vertex-template/components/features/sites/site-information-card.tsx
new file mode 100644
index 0000000000..2aac3f3d5f
--- /dev/null
+++ b/src/vertex-template/components/features/sites/site-information-card.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { Site } from "@/app/types/sites";
+import { Card } from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { AqEdit01, AqCopy01 } from "@airqo/icons-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import ReusableToast from "@/components/shared/toast/ReusableToast";
+import {
+ badgeColorClasses,
+ formatDisplayDate,
+ getDeviceStatus,
+ getStatusExplanation,
+} from "@/core/utils/status";
+
+interface SiteInformationCardProps {
+ site: Site;
+ onEdit?: () => void;
+}
+
+const DetailItem = ({ label, value }: { label: string; value: React.ReactNode }) => (
+
+
{label}
+
+ {value !== null && value !== undefined && !(typeof value === "string" && value.trim() === "") ? value : "N/A"}
+
+
+);
+
+export const SiteInformationCard: React.FC = ({ site, onEdit }) => {
+ const lastActiveCheck = site.lastActive
+ ? formatDisplayDate(site.lastActive)
+ : null;
+
+ const status = getDeviceStatus(
+ site.isOnline || false,
+ site.rawOnlineStatus,
+ lastActiveCheck
+ );
+ const colors = badgeColorClasses[status.color];
+ const Icon = status.icon;
+ const explanation = getStatusExplanation(status.label, lastActiveCheck);
+
+ return (
+
+
+
Site Details
+
+
+
+
+ {site.latitude}} />
+ {site.longitude}} />
+
+
+
+
+
+ {site._id}
+ {
+ if (site._id) {
+ try {
+ await navigator.clipboard.writeText(site._id);
+ ReusableToast({ message: "Copied", type: "SUCCESS" });
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ ReusableToast({ message: "Failed to copy", type: "ERROR" });
+ }
+ }
+ }}
+ className="p-1 h-auto hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
+ Icon={AqCopy01}
+ />
+
+ }
+ />
+
{site.altitude} m} />
+
+
+
Status
+
+
+
+
+
+
+ {status.label}
+
+
+
+ {explanation}
+
+
+
+
+
+
+
+ {onEdit && (
+
+
+ Edit
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/vertex-template/components/features/sites/site-measurements-api-card.tsx b/src/vertex-template/components/features/sites/site-measurements-api-card.tsx
new file mode 100644
index 0000000000..afbb9befe1
--- /dev/null
+++ b/src/vertex-template/components/features/sites/site-measurements-api-card.tsx
@@ -0,0 +1,59 @@
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Copy } from "lucide-react";
+import React from "react";
+import ReusableToast from "@/components/shared/toast/ReusableToast";
+
+interface SiteMeasurementsApiCardProps {
+ siteId: string;
+}
+
+const SiteMeasurementsApiCard: React.FC = ({ siteId }) => {
+ return (
+
+ Site Measurements API
+ {/* Recent Measurements */}
+
+
Recent Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/recent?token=YOUR_TOKEN`}
+
+
{
+ navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/recent?token=YOUR_TOKEN`);
+ ReusableToast({ message: "Copied", type: "SUCCESS" });
+ }}
+ >
+
+
+
+
+ {/* Historical Measurements */}
+
+
Historical Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/historical?token=YOUR_TOKEN`}
+
+
{
+ navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/historical?token=YOUR_TOKEN`);
+ ReusableToast({ message: "Copied", type: "SUCCESS" });
+ }}
+ >
+
+
+
+
+
+ );
+};
+
+export default SiteMeasurementsApiCard;
diff --git a/src/vertex-template/components/features/sites/site-mobile-app-card.tsx b/src/vertex-template/components/features/sites/site-mobile-app-card.tsx
new file mode 100644
index 0000000000..430db34ae8
--- /dev/null
+++ b/src/vertex-template/components/features/sites/site-mobile-app-card.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { AqEdit01 } from "@airqo/icons-react";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+
+interface Site {
+ search_name?: string;
+ location_name?: string;
+ city?: string;
+ country?: string;
+}
+
+interface SiteMobileAppCardProps {
+ site: Site;
+ onEdit?: () => void;
+}
+
+const DetailItem = ({ label, value }: { label: string; value: React.ReactNode }) => (
+
+
{label}
+
{value || "N/A"}
+
+);
+
+export const SiteMobileAppCard: React.FC = ({ site, onEdit }) => {
+ return (
+
+
+
Mobile App Details
+
+
+
+
+
+
+
+ {onEdit && (
+
+
+ Edit
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/vertex-template/components/features/sites/site-stats-cards.tsx b/src/vertex-template/components/features/sites/site-stats-cards.tsx
new file mode 100644
index 0000000000..b9b7d4875c
--- /dev/null
+++ b/src/vertex-template/components/features/sites/site-stats-cards.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ AqMonitor,
+ AqCollocation,
+ AqWifiOff,
+ AqData,
+} from "@airqo/icons-react";
+import { StatCard } from "@/components/features/dashboard/stat-card";
+import { getStatusExplanation } from "@/core/utils/status";
+import { useMemo } from "react";
+import { useSiteStatistics } from "@/core/hooks/useSites";
+
+interface SiteStatsCardsProps {
+ network?: string;
+}
+
+export const SiteStatsCards = ({ network }: SiteStatsCardsProps) => {
+ const { summary, isLoading } = useSiteStatistics(network);
+
+ const metrics = useMemo(() => {
+ return {
+ total: summary?.total_sites ?? 0,
+ operational: summary?.operational ?? 0,
+ transmitting: summary?.transmitting ?? 0,
+ notTransmitting: summary?.not_transmitting ?? 0,
+ dataAvailable: summary?.data_available ?? 0,
+ };
+ }, [summary]);
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentStatus = searchParams.get("status");
+
+ const handleFilter = (status?: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (status) {
+ params.set("status", status);
+ } else {
+ params.delete("status");
+ }
+ router.push(`?${params.toString()}`);
+ };
+
+ return (
+
+
+ }
+ description={`All sites${network ? ` in ${network}` : ''}.`}
+ isLoading={isLoading}
+ onClick={() => handleFilter()}
+ variant="primary"
+ size="sm"
+ isActive={!currentStatus}
+ />
+
+ }
+ description={getStatusExplanation("Operational")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('operational')}
+ variant="success"
+ size="sm"
+ isActive={currentStatus === 'operational'}
+ />
+
+ }
+ description={getStatusExplanation("Transmitting")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('transmitting')}
+ variant="info"
+ size="sm"
+ isActive={currentStatus === 'transmitting'}
+ />
+
+ }
+ description={getStatusExplanation("Not Transmitting")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('not-transmitting')}
+ variant="default" // Using default (gray) for inactive/issue
+ size="sm"
+ isActive={currentStatus === 'not-transmitting'}
+ />
+
+ }
+ description={getStatusExplanation("Data Available")}
+ isLoading={isLoading}
+ onClick={() => handleFilter('data-available')}
+ variant="warning"
+ size="sm"
+ isActive={currentStatus === 'data-available'}
+ />
+
+
+ );
+};
diff --git a/src/vertex-template/components/features/sites/sites-list-table.tsx b/src/vertex-template/components/features/sites/sites-list-table.tsx
new file mode 100644
index 0000000000..b13de9f345
--- /dev/null
+++ b/src/vertex-template/components/features/sites/sites-list-table.tsx
@@ -0,0 +1,191 @@
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import ReusableTable, { TableColumn, TableItem } from "@/components/shared/table/ReusableTable";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Site } from "@/app/types/sites";
+import { useSites } from "@/core/hooks/useSites";
+import { useMemo } from "react";
+import { useServerSideTableState } from "@/core/hooks/useServerSideTableState";
+import {
+ badgeColorClasses,
+ formatDisplayDate,
+ getDeviceStatus,
+} from "@/core/utils/status";
+
+interface SitesTableProps {
+ itemsPerPage?: number;
+ onSiteClick?: (site: Site) => void;
+ multiSelect?: boolean;
+ onSelectedSitesChange?: (selectedSites: Site[]) => void;
+ className?: string;
+ basePath?: string;
+}
+
+type TableSite = TableItem;
+
+export default function SitesTable({
+ itemsPerPage = 25,
+ onSiteClick,
+ multiSelect = false,
+ onSelectedSitesChange,
+ className,
+ basePath = "/admin/sites",
+}: SitesTableProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const status = searchParams.get("status") || undefined;
+
+ const {
+ pagination, setPagination,
+ searchTerm, setSearchTerm,
+ sorting, setSorting
+ } = useServerSideTableState({ initialPageSize: itemsPerPage });
+
+ const { sites, meta, isFetching, error } = useSites({
+ page: pagination.pageIndex + 1,
+ limit: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sorting[0]?.id,
+ order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined,
+ status
+ });
+
+ const pageCount = meta?.totalPages ?? 0;
+
+ const handleSiteClick = (item: unknown) => {
+ const site = item as Site;
+ if (onSiteClick) onSiteClick(site);
+ else if (site._id) router.push(`${basePath}/${site._id}`);
+ };
+
+ const handleSelectedItemsChange = (selectedIds: (string | number)[]) => {
+ if (onSelectedSitesChange) {
+ const selected = sites.filter((s) => s._id && selectedIds.includes(s._id));
+ onSelectedSitesChange(selected);
+ }
+ };
+
+ const sitesWithId: TableSite[] = useMemo(
+ () =>
+ sites
+ .filter(
+ (s): s is Site & { _id: string } =>
+ typeof s._id === "string" && s._id.trim() !== ""
+ )
+ .map((s) => ({ ...s, id: s._id })),
+ [sites]
+ );
+
+ const columns: TableColumn[] = [
+ {
+ key: "name",
+ label: "Name",
+ render: (value) => {
+ const name = typeof value === "string" ? value : "";
+ return {name} ;
+ },
+ },
+ {
+ key: "generated_name",
+ label: "Site ID",
+ render: (value) => {
+ const id = typeof value === "string" ? value : "";
+ return {id} ;
+ },
+ },
+ {
+ key: "description",
+ label: "Description",
+ render: (value) => {
+ const desc = typeof value === "string" ? value : "";
+ return (
+
+ {desc}
+
+ );
+ },
+ },
+ {
+ key: "country",
+ label: "Country",
+ render: (value) => {
+ const country = typeof value === "string" ? value : "";
+ return {country} ;
+ },
+ },
+ {
+ key: "district",
+ label: "District",
+ render: (value) => {
+ const district = typeof value === "string" ? value : "";
+ return {district} ;
+ },
+ },
+ {
+ key: "isOnline",
+ label: "Status",
+ render: (isOnline, item) => {
+ // Cast item to Site to access lastActive
+ const site = item as unknown as Site;
+ const lastActiveCheck = site.lastActive
+ ? formatDisplayDate(site.lastActive)
+ : null;
+
+ const status = getDeviceStatus(
+ site.isOnline || false,
+ site.rawOnlineStatus,
+ lastActiveCheck
+ );
+ const colors = badgeColorClasses[status.color];
+ const Icon = status.icon;
+
+ return (
+
+
+ {status.label}
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+
+
+ Unable to load sites
+
+ {error?.message || "An unknown error occurred"}
+
+
+ ) : (
+ "No sites available"
+ )
+ }
+ />
+
+ );
+}
diff --git a/src/vertex-template/components/layout/AppDropdown.tsx b/src/vertex-template/components/layout/AppDropdown.tsx
new file mode 100644
index 0000000000..602c61b1a9
--- /dev/null
+++ b/src/vertex-template/components/layout/AppDropdown.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import React, { useState } from 'react';
+import Image from 'next/image';
+import {
+ AqDotsGrid,
+ AqCalibration,
+ AqGlobe02Maps_Travel,
+ AqBookOpen01,
+ AqPhone01,
+ AqArrowNarrowLeft,
+ AqCpuChip01,
+ AqServer03,
+ AqBarChartSquarePlus,
+} from '@airqo/icons-react';
+import { Smartphone } from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+} from '@/components/ui/dropdown-menu';
+
+interface App {
+ name: string;
+ icon: React.ComponentType<{ className?: string }>;
+ href?: string;
+ type?: 'qr';
+ color: string;
+}
+
+interface AppDropdownProps {
+ className?: string;
+}
+
+const AppDropdown: React.FC = ({ className = '' }) => {
+ const [showQRCode, setShowQRCode] = useState(false);
+ const isProduction = process.env.NODE_ENV === 'production';
+
+ const getUrl = (baseUrl: string): string => {
+ if (isProduction) return baseUrl;
+ try {
+ const url = new URL(baseUrl);
+ url.hostname = `staging-${url.hostname}`;
+ return url.toString();
+ } catch {
+ return baseUrl;
+ }
+ };
+
+ const apps: App[] = [
+ {
+ name: 'Analytics',
+ icon: AqBarChartSquarePlus,
+ href: getUrl('https://analytics.airqo.net/'),
+ color: 'bg-green-500',
+ },
+ {
+ name: 'Calibrate',
+ icon: AqCalibration,
+ href: getUrl('https://airqalibrate.airqo.net/'),
+ color: 'bg-blue-500',
+ },
+ {
+ name: 'Website',
+ icon: AqGlobe02Maps_Travel,
+ href: 'https://airqo.net/',
+ color: 'bg-purple-500',
+ },
+ {
+ name: 'Vertex',
+ icon: AqServer03,
+ href: getUrl('https://vertex.airqo.net/'),
+ color: 'bg-yellow-600',
+ },
+ {
+ name: 'API Docs',
+ icon: AqBookOpen01,
+ href: 'https://docs.airqo.net/airqo-rest-api-documentation/',
+ color: 'bg-orange-500',
+ },
+ {
+ name: 'Mobile App',
+ icon: AqPhone01,
+ type: 'qr',
+ color: 'bg-indigo-500',
+ },
+ {
+ name: 'AI platform',
+ icon: AqCpuChip01,
+ href: 'https://ai.airqo.net',
+ color: 'bg-pink-500',
+ },
+ ];
+
+ const handleAppClick = (app: App): void => {
+ if (app.type === 'qr') {
+ setShowQRCode(true);
+ } else if (app.href) {
+ window.open(app.href, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ const handleBackClick = (): void => {
+ setShowQRCode(false);
+ };
+
+ const openAppStore = (url: string): void => {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ };
+
+ return (
+
+
+
+
+
+
+ {!showQRCode ? (
+
+ {apps.map(app => {
+ const Icon = app.icon;
+ return (
+
handleAppClick(app)}
+ className="flex flex-col items-center gap-3 p-3 rounded-md hover:bg-muted focus:outline-none transition-colors"
+ aria-label={app.name}
+ >
+
+
+
+
+ {app.name}
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+
+
+
Get AirQo Mobile App
+
+
+
+
+
+
+ Scan with your phone camera
+
+
+ Or search "AirQo" in your app store
+
+
+
+
+ openAppStore(
+ 'https://play.google.com/store/apps/details?id=com.airqo.app'
+ )
+ }
+ className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-black text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
+ aria-label="Download on Google Play"
+ >
+
+ Google Play
+
+
+ openAppStore(
+ 'https://apps.apple.com/ug/app/airqo-air-quality/id1337573091'
+ )
+ }
+ className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-black text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
+ aria-label="Download on App Store"
+ >
+
+ App Store
+
+
+
+ )}
+
+
+ );
+};
+
+export default AppDropdown;
diff --git a/src/vertex-template/components/layout/Footer.tsx b/src/vertex-template/components/layout/Footer.tsx
new file mode 100644
index 0000000000..352b51b670
--- /dev/null
+++ b/src/vertex-template/components/layout/Footer.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import * as React from 'react';
+
+export const Footer: React.FC = () => {
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/vertex-template/components/layout/NavItem.tsx b/src/vertex-template/components/layout/NavItem.tsx
new file mode 100644
index 0000000000..dd94e49fc9
--- /dev/null
+++ b/src/vertex-template/components/layout/NavItem.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import * as React from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { cn } from '@/lib/utils';
+
+export interface NavItemType {
+ href: string;
+ icon: React.ElementType;
+ label: string;
+ disabled?: boolean;
+ badge?: string | number;
+ activeOverride?: boolean;
+ endIcon?: React.ElementType;
+}
+
+export interface NavItemProps {
+ item: NavItemType;
+ isCollapsed?: boolean;
+ onClick?: (e?: React.MouseEvent) => void;
+ className?: string;
+}
+
+export const NavItem = React.memo(
+ ({ item, isCollapsed = false, onClick, className }) => {
+ const pathname = usePathname();
+ const isActive =
+ typeof item.activeOverride === 'boolean'
+ ? item.activeOverride
+ : pathname === item.href || (item.href !== '/' && pathname.startsWith(`${item.href}/`));
+ const Icon = item.icon;
+
+ const baseStyles = cn(
+ 'relative flex items-center transition-all duration-300 ease-in-out',
+ 'focus-visible:outline-none'
+ );
+
+ const textClass = isActive ? 'text-primary dark:text-primary' : 'text-foreground dark:text-gray-300';
+ const iconColor = isActive ? 'text-primary dark:text-primary' : 'text-gray-500 dark:text-gray-400';
+ const bgClass = isActive
+ ? 'bg-primary/10 dark:bg-primary/20'
+ : 'hover:bg-muted dark:hover:bg-zinc-800 dark:text-gray-300 dark:hover:text-white';
+
+ // Handle disabled state
+ const isDisabled = item.disabled;
+ const disabledStyles = isDisabled
+ ? 'opacity-50 cursor-not-allowed pointer-events-none'
+ : '';
+
+ if (isCollapsed) {
+ return (
+
+ onClick && onClick(e)}
+ className={cn(
+ baseStyles,
+ 'w-10 h-10 rounded-lg justify-center',
+ bgClass,
+ disabledStyles,
+ className
+ )}
+ aria-current={isActive ? 'page' : undefined}
+ >
+
+ {item.label}
+
+
+ );
+ }
+
+ return (
+
+ {isActive && (
+
+
+
+ )}
+
+
onClick && onClick(e)}
+ className={cn(
+ baseStyles,
+ 'w-full gap-3 py-2.5 px-3 rounded-lg',
+ bgClass,
+ disabledStyles,
+ className
+ )}
+ aria-current={isActive ? 'page' : undefined}
+ >
+
+
+
+
+
+
+ {item.label}
+
+
+
+ {item.endIcon && (
+
+
+
+ )}
+
+ {item.badge && (
+
+ {item.badge}
+
+ )}
+
+
+ );
+ }
+);
+
+NavItem.displayName = 'NavItem';
\ No newline at end of file
diff --git a/src/vertex-template/components/layout/accessConfig/context-guard.tsx b/src/vertex-template/components/layout/accessConfig/context-guard.tsx
new file mode 100644
index 0000000000..3fda41da35
--- /dev/null
+++ b/src/vertex-template/components/layout/accessConfig/context-guard.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import React from "react";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { UserContext } from "@/core/redux/slices/userSlice";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertTriangle } from "lucide-react";
+
+interface ContextGuardProps {
+ allowedContexts: UserContext[];
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+ showError?: boolean;
+}
+
+export const ContextGuard: React.FC = ({
+ allowedContexts,
+ children,
+ fallback,
+ showError = false,
+}) => {
+ const { userContext } = useUserContext();
+ const hasValidContext = userContext ? allowedContexts.includes(userContext) : false;
+
+ if (hasValidContext) {
+ return <>{children}>;
+ }
+
+ if (fallback) {
+ return <>{fallback}>;
+ }
+
+ if (showError) {
+ return (
+
+
+
+ This feature is not available in {userContext} context.
+
+ Required: {allowedContexts.join(', ')}
+
+
+ );
+ }
+
+ return null;
+};
+
+// Higher-order component for context-based rendering
+export const withContext = (
+ allowedContexts: UserContext[],
+ fallback?: React.ComponentType>
+) => {
+ return (Component: React.ComponentType
) => {
+ const WrappedComponent = (props: P) => {
+ const { userContext } = useUserContext();
+ const hasValidContext = userContext ? allowedContexts.includes(userContext) : false;
+
+ if (!hasValidContext) {
+ return fallback ? React.createElement(fallback, { ...(props as Record) }) : null;
+ }
+
+ return ;
+ };
+
+ WrappedComponent.displayName = `withContext(${Component.displayName || Component.name})`;
+ return WrappedComponent;
+ };
+};
\ No newline at end of file
diff --git a/src/vertex-template/components/layout/accessConfig/forbidden-guard.tsx b/src/vertex-template/components/layout/accessConfig/forbidden-guard.tsx
new file mode 100644
index 0000000000..7b5b118ba7
--- /dev/null
+++ b/src/vertex-template/components/layout/accessConfig/forbidden-guard.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import React from "react";
+import { useAppSelector } from "@/core/redux/hooks";
+import { ForbiddenPage } from "@/components/ui/forbidden-page";
+
+interface ForbiddenGuardProps {
+ children: React.ReactNode;
+}
+
+export const ForbiddenGuard: React.FC = ({ children }) => {
+ const forbiddenState = useAppSelector((state) => state.user.forbidden);
+ const isOrganizationSwitching = useAppSelector(
+ (state) => state.user.organizationSwitching.isSwitching
+ );
+
+ // If user is in forbidden state, show forbidden page
+ if (forbiddenState.isForbidden && !isOrganizationSwitching) {
+ return ;
+ }
+
+ // Otherwise, render children normally
+ return <>{children}>;
+};
\ No newline at end of file
diff --git a/src/vertex-template/components/layout/accessConfig/permission-guard.tsx b/src/vertex-template/components/layout/accessConfig/permission-guard.tsx
new file mode 100644
index 0000000000..b6bf434004
--- /dev/null
+++ b/src/vertex-template/components/layout/accessConfig/permission-guard.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import React from "react";
+import { usePermission, usePermissionCheck } from "@/core/hooks/usePermissions";
+import { Permission } from "@/core/permissions/constants";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Shield, AlertTriangle } from "lucide-react";
+
+interface PermissionGuardProps {
+ permission: Permission;
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+ showError?: boolean;
+ resourceContext?: {
+ organizationId?: string;
+ deviceId?: string;
+ siteId?: string;
+ userId?: string;
+ };
+}
+
+export const PermissionGuard: React.FC = ({
+ permission,
+ children,
+ fallback,
+ showError = false,
+ resourceContext,
+}) => {
+ const hasPermission = usePermission(permission, { resourceContext });
+ const permissionCheck = usePermissionCheck(permission, { resourceContext });
+
+ if (hasPermission) {
+ return <>{children}>;
+ }
+
+ if (fallback) {
+ return <>{fallback}>;
+ }
+
+ if (showError) {
+ return (
+
+
+
+ Access denied. {permissionCheck.reason}
+ {permissionCheck.role && ` (Current role: ${permissionCheck.role})`}
+
+
+ );
+ }
+
+ return null;
+};
+
+// Higher-order component for permission-based rendering
+export const withPermission = (
+ permission: Permission,
+ fallback?: React.ComponentType>,
+ resourceContext?: PermissionGuardProps["resourceContext"]
+) => {
+ return (Component: React.ComponentType
) => {
+ const WrappedComponent = (props: P) => {
+ const hasPermission = usePermission(permission, { resourceContext });
+
+ if (!hasPermission) {
+ return fallback ? React.createElement(fallback, { ...(props as Record) }) : null;
+ }
+
+ return ;
+ };
+
+ WrappedComponent.displayName = `withPermission(${Component.displayName || Component.name})`;
+ return WrappedComponent;
+ };
+};
+
+// Component for showing permission requirements
+export const PermissionRequirement: React.FC<{
+ permission: Permission;
+ title?: string;
+ description?: string;
+}> = ({ permission, title = "Permission Required", description }) => {
+ const permissionCheck = usePermissionCheck(permission);
+
+ return (
+
+
+
+
+
{title}
+
+ {description || `This action requires the "${permission}" permission.`}
+
+ {permissionCheck.role && (
+
+ Current role: {permissionCheck.role}
+
+ )}
+ {permissionCheck.reason && (
+
+ Reason: {permissionCheck.reason}
+
+ )}
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/layout/accessConfig/route-guard.tsx b/src/vertex-template/components/layout/accessConfig/route-guard.tsx
new file mode 100644
index 0000000000..84a5358b1f
--- /dev/null
+++ b/src/vertex-template/components/layout/accessConfig/route-guard.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import React from "react";
+import { useAppSelector } from "@/core/redux/hooks";
+import { usePermission, useUserRole, useHasAnyPermission } from "@/core/hooks/usePermissions";
+import { Permission, RoleName } from "@/core/permissions/constants";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { ForbiddenError } from "@/components/ui/forbidden-error";
+import { useUserContext } from "@/core/hooks/useUserContext";
+import { UserContext } from "@/core/redux/slices/userSlice";
+
+interface RouteGuardProps {
+ permission?: Permission;
+ permissions?: Permission[];
+ role?: RoleName;
+ roles?: RoleName[];
+ children: React.ReactNode;
+ redirectTo?: string;
+ showError?: boolean;
+ allowedContexts?: UserContext[];
+ resourceContext?: {
+ organizationId?: string;
+ deviceId?: string;
+ siteId?: string;
+ userId?: string;
+ };
+}
+
+export const RouteGuard: React.FC = ({
+ permission,
+ permissions,
+ role,
+ roles,
+ children,
+ redirectTo = "/unauthorized",
+ showError = true,
+ allowedContexts,
+ resourceContext,
+}) => {
+ const router = useRouter();
+ const { userContext, isLoading } = useUserContext();
+ const isOrganizationSwitching = useAppSelector(
+ (state) => state.user.organizationSwitching.isSwitching
+ );
+ const hasPermission = usePermission(permission || '' as Permission, { resourceContext });
+ const hasAnyPermission = useHasAnyPermission(permissions || [], { resourceContext });
+ const userRole = useUserRole(resourceContext?.organizationId);
+
+ // Validate that at least one access control method is specified
+ if (!permission && !permissions && !role && !roles) {
+ throw new Error('RouteGuard requires either permission, permissions, role, or roles prop');
+ }
+
+ // Check role-based access
+ const hasRoleAccess = React.useMemo(() => {
+ if (!role && !roles) return false;
+ if (!userRole) return false;
+
+ if (role) {
+ return userRole === role;
+ }
+
+ if (roles && roles.length > 0) {
+ return roles.includes(userRole as RoleName);
+ }
+
+ return false;
+ }, [role, roles, userRole]);
+
+ // Check permission-based access
+ const hasPermissionAccess = permission ? hasPermission : (permissions ? hasAnyPermission : false);
+
+ const hasValidContext = !allowedContexts || (userContext !== null && allowedContexts.includes(userContext));
+
+ const hasAccess = hasValidContext && (hasPermissionAccess || hasRoleAccess);
+
+ useEffect(() => {
+ if (isLoading || isOrganizationSwitching) {
+ return;
+ }
+
+ if (!hasAccess && !showError) {
+ router.push(redirectTo);
+ }
+ }, [hasAccess, isLoading, isOrganizationSwitching, router, redirectTo, showError]);
+
+ if (isLoading || isOrganizationSwitching) {
+ return null;
+ }
+
+ if (!hasAccess) {
+ if (showError) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ }
+
+ return <>{children}>;
+};
+
+// Higher-order component for route-level permission protection
+export const withRoutePermission = (
+ permission: Permission,
+ redirectTo?: string,
+ resourceContext?: RouteGuardProps["resourceContext"]
+) => {
+ return (Component: React.ComponentType
) => {
+ const WrappedComponent = (props: P) => {
+ const hasPermission = usePermission(permission, { resourceContext });
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!hasPermission) {
+ router.push(redirectTo || "/unauthorized");
+ }
+ }, [hasPermission, router]);
+
+ if (!hasPermission) {
+ return null;
+ }
+
+ return ;
+ };
+
+ WrappedComponent.displayName = `withRoutePermission(${Component.displayName || Component.name})`;
+ return WrappedComponent;
+ };
+};
+
+// Higher-order component for route-level role protection
+export const withRouteRole = (
+ role: RoleName,
+ redirectTo?: string,
+ organizationId?: string
+) => {
+ return
(Component: React.ComponentType
) => {
+ const WrappedComponent = (props: P) => {
+ const userRole = useUserRole(organizationId);
+ const router = useRouter();
+
+ useEffect(() => {
+ if (userRole !== role) {
+ router.push(redirectTo || "/unauthorized");
+ }
+ }, [userRole, router]);
+
+ if (userRole !== role) {
+ return null;
+ }
+
+ return ;
+ };
+
+ WrappedComponent.displayName = `withRouteRole(${Component.displayName || Component.name})`;
+ return WrappedComponent;
+ };
+};
+
diff --git a/src/vertex-template/components/layout/layout.tsx b/src/vertex-template/components/layout/layout.tsx
new file mode 100644
index 0000000000..40440fff5a
--- /dev/null
+++ b/src/vertex-template/components/layout/layout.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { usePathname, useRouter } from 'next/navigation';
+import { useAppSelector } from '@/core/redux/hooks';
+import { motion } from 'framer-motion';
+import Topbar from './topbar';
+import PrimarySidebar from './primary-sidebar';
+import SecondarySidebar from './secondary-sidebar';
+import SessionLoadingState from './loading/session-loading';
+import ErrorBoundary from '../shared/ErrorBoundary';
+import Footer from './Footer';
+import { FeedbackLauncher } from '../features/feedback/feedback-launcher';
+import { PageSatisfactionBanner } from '../features/feedback/page-satisfaction-banner';
+import { GlobalBannerContainer } from '@/context/banner-context';
+
+import { setLastActiveModule } from '@/core/utils/userPreferences';
+
+interface LayoutProps {
+ children: React.ReactNode;
+}
+
+export default function Layout({ children }: LayoutProps) {
+ const [isPrimarySidebarOpen, setIsPrimarySidebarOpen] = useState(false);
+ const [isSecondarySidebarCollapsed, setIsSecondarySidebarCollapsed] =
+ useState(false);
+ const [isNavigating, setIsNavigating] = useState(false);
+ const pathname = usePathname();
+ const [activeModule, setActiveModule] = useState<'devices' | 'admin'>(
+ pathname?.startsWith('/admin/') ? 'admin' : 'devices'
+ );
+ const router = useRouter();
+
+ const { isSwitching } = useAppSelector(
+ state => state.user.organizationSwitching
+ );
+
+ const isLoggingOut = useAppSelector(state => state.user.isLoggingOut);
+ const userDetails = useAppSelector(state => state.user.userDetails);
+
+ useEffect(() => {
+ if (!pathname) return;
+
+ // Reset navigation shimmer on path change
+ setIsNavigating(false);
+
+ const newModule: 'devices' | 'admin' = pathname.startsWith('/admin/')
+ ? 'admin'
+ : 'devices';
+
+ // Only update state if it's actually changing
+ setActiveModule(prev => (prev === newModule ? prev : newModule));
+
+ // Save preference when module changes
+ const email = userDetails?.email || userDetails?.userName;
+ if (email) {
+ setLastActiveModule(newModule, email);
+ }
+ }, [pathname, userDetails]);
+
+ useEffect(() => {
+ // Warm key routes so sidebar navigation resolves near-instantly.
+ const routesToPrefetch = [
+ '/home',
+ '/devices/my-devices',
+ '/cohorts',
+ '/admin/networks',
+ '/admin/networks/requests',
+ '/admin/cohorts',
+ '/admin/sites',
+ '/admin/grids',
+ '/admin/shipping',
+ ];
+
+ routesToPrefetch.forEach(route => {
+ router.prefetch(route);
+ });
+ }, [router]);
+
+ const handleModuleChange = (module: string, targetPath?: string) => {
+ const targetModule = module as 'devices' | 'admin';
+ if (targetModule === activeModule && !targetPath) {
+ setIsPrimarySidebarOpen(false);
+ return;
+ }
+
+ // Keep module switch path-driven to avoid nav/content mismatch during transitions.
+ setIsPrimarySidebarOpen(false);
+
+ // Navigate to module default route or specific target
+ const defaultRoutes: Record = {
+ devices: '/home',
+ admin: '/admin/networks',
+ };
+
+ const nextPath = targetPath || defaultRoutes[module] || '/home';
+ setIsNavigating(true);
+ router.prefetch(nextPath);
+ router.push(nextPath);
+ };
+
+ const toggleSecondarySidebar = () => {
+ setIsSecondarySidebarCollapsed(!isSecondarySidebarCollapsed);
+ };
+
+ return (
+ <>
+ {isLoggingOut ? (
+
+
+
+ ) : (
+
+ {(isSwitching || isNavigating) && (
+
+ )}
+
setIsPrimarySidebarOpen(true)} />
+ setIsPrimarySidebarOpen(false)}
+ activeModule={activeModule}
+ onModuleChange={handleModuleChange}
+ onNavigate={() => setIsNavigating(true)}
+ />
+ setIsNavigating(true)}
+ />
+
+
+
+
+
+ {children}
+
+
+
+
+ {userDetails && (
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/vertex-template/components/layout/loading/org-loading.tsx b/src/vertex-template/components/layout/loading/org-loading.tsx
new file mode 100644
index 0000000000..4fb8783111
--- /dev/null
+++ b/src/vertex-template/components/layout/loading/org-loading.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+// Loading component for organization switching
+const OrganizationLoadingState = ({ organizationName }: { organizationName: string }) => (
+
+
+ {/* Spinner */}
+
+
+
+ {/* Organization name */}
+
+ {organizationName}
+
+
+ {/* Loading text */}
+
+ Setting up workspace...
+
+
+
+
+);
+
+export default OrganizationLoadingState;
\ No newline at end of file
diff --git a/src/vertex-template/components/layout/loading/session-loading.tsx b/src/vertex-template/components/layout/loading/session-loading.tsx
new file mode 100644
index 0000000000..240e5e2527
--- /dev/null
+++ b/src/vertex-template/components/layout/loading/session-loading.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+// Loading component for session loading
+const SessionLoadingState = () => (
+
+);
+
+export default SessionLoadingState;
diff --git a/src/vertex-template/components/layout/primary-sidebar.tsx b/src/vertex-template/components/layout/primary-sidebar.tsx
new file mode 100644
index 0000000000..41dfcee7de
--- /dev/null
+++ b/src/vertex-template/components/layout/primary-sidebar.tsx
@@ -0,0 +1,409 @@
+'use client';
+
+import React, { useState } from 'react';
+import { X, ShieldCheck, ChevronDown, ChevronRight, Clock } from 'lucide-react';
+import { AqHomeSmile } from '@airqo/icons-react';
+import { Button } from '@/components/ui/button';
+import { motion } from 'framer-motion';
+import Image from 'next/image';
+import { NavItem } from './NavItem';
+import { useUserContext } from '@/core/hooks/useUserContext';
+import { useRecentlyVisited } from '@/core/hooks/useRecentlyVisited';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useRouter, usePathname } from 'next/navigation';
+import { cn } from '@/lib/utils';
+import { ROUTE_LINKS } from '@/core/routes';
+import { PERMISSIONS } from '@/core/permissions/constants';
+import PermissionTooltip from '@/components/ui/permission-tooltip';
+import { vertexConfig } from '@/vertex.config';
+
+interface PrimarySidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+ activeModule: string;
+ onModuleChange: (module: string, targetPath?: string) => void;
+ onNavigate?: () => void;
+}
+
+interface AdminDropdownItemProps {
+ permission: boolean;
+ permissionCode: string;
+ tooltipMessage: string;
+ onClick: () => void;
+ label: string;
+ subLabel: string;
+ isActive: boolean;
+}
+
+const AdminDropdownItem: React.FC = ({
+ permission,
+ permissionCode,
+ tooltipMessage,
+ onClick,
+ label,
+ subLabel,
+ isActive,
+}) => {
+ const item = (
+
+ {label}
+
+ {subLabel}
+
+
+ );
+
+ return (
+
+ {!permission ? (
+
+ {item}
+
+ ) : (
+ item
+ )}
+
+ );
+};
+
+const PrimarySidebar: React.FC = ({
+ isOpen,
+ onClose,
+ activeModule,
+ onModuleChange: handleModuleChange,
+ onNavigate,
+}) => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { getContextPermissions } = useUserContext();
+ const permissions = getContextPermissions();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isRecentOpen, setIsRecentOpen] = useState(false);
+ const { visitedPages } = useRecentlyVisited();
+ const { activeGroup } = useUserContext();
+ const timeoutRef = React.useRef(null);
+ const recentTimeoutRef = React.useRef(null);
+
+ // Determine if user can view Admin Panel (Role based ONLY)
+ const canViewAdminPanel = React.useMemo(() => {
+ if (!activeGroup?.role?.role_name) return false;
+
+ const allowedRoles = ['AIRQO_SUPER_ADMIN', 'AIRQO_ADMIN', 'AIRQO_NETWORK_ADMIN'];
+ return allowedRoles.includes(activeGroup.role.role_name);
+ }, [activeGroup]);
+
+ const navigateWithShimmer = (targetPath: string, navigate: () => void) => {
+ if (pathname === targetPath) return;
+ router.prefetch(targetPath);
+ onNavigate?.();
+ navigate();
+ };
+
+ React.useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ if (recentTimeoutRef.current) clearTimeout(recentTimeoutRef.current);
+ };
+ }, []);
+
+ return (
+ <>
+ {isOpen && (
+
+ )}
+
+
+
+
+
+
{vertexConfig.org.name}
+ {activeGroup?.grp_title && (
+
+
+ {activeGroup.grp_title.replace(/[_-]/g, " ")}
+
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Home - visible to ALL users */}
+ {
+ navigateWithShimmer(ROUTE_LINKS.HOME, () => handleModuleChange('devices'));
+ }}
+ />
+
+ {/* Administrative Panel - visible to authorized admin roles */}
+ {canViewAdminPanel && (
+
+
+ {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ setIsDropdownOpen(true);
+ }}
+ onMouseLeave={() => {
+ timeoutRef.current = setTimeout(() => setIsDropdownOpen(false), 200);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setIsDropdownOpen(!isDropdownOpen);
+ }
+ }}
+ >
+ { e?.preventDefault(); }}
+ />
+
+
+ {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ }}
+ onMouseLeave={() => {
+ timeoutRef.current = setTimeout(() => setIsDropdownOpen(false), 200);
+ }}
+ >
+
+ Administrative Panel
+
+
+
+ {
+ navigateWithShimmer(ROUTE_LINKS.ADMIN_NETWORKS, () => {
+ handleModuleChange('admin', ROUTE_LINKS.ADMIN_NETWORKS);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Sensor Manufacturers"
+ subLabel="Manage and configure devices"
+ isActive={pathname === ROUTE_LINKS.ADMIN_NETWORKS}
+ />
+
+ {vertexConfig.features.networkRequests && (
+ {
+ navigateWithShimmer(ROUTE_LINKS.ADMIN_NETWORK_REQUESTS, () => {
+ handleModuleChange('admin', ROUTE_LINKS.ADMIN_NETWORK_REQUESTS);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Manufacturer Requests"
+ subLabel="Review new sensor requests"
+ isActive={pathname === ROUTE_LINKS.ADMIN_NETWORK_REQUESTS}
+ />
+ )}
+
+ {
+ navigateWithShimmer(ROUTE_LINKS.COHORTS, () => {
+ handleModuleChange('admin', ROUTE_LINKS.COHORTS);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Cohorts"
+ subLabel="Group devices for analytics"
+ isActive={pathname.startsWith(ROUTE_LINKS.COHORTS)}
+ />
+
+ {vertexConfig.features.siteManagement && (
+ {
+ navigateWithShimmer(ROUTE_LINKS.SITES, () => {
+ handleModuleChange('admin', ROUTE_LINKS.SITES);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Sites"
+ subLabel="Manage location deployments"
+ isActive={pathname.startsWith(ROUTE_LINKS.SITES)}
+ />
+ )}
+
+ {vertexConfig.features.siteManagement && (
+ {
+ navigateWithShimmer(ROUTE_LINKS.GRIDS, () => {
+ handleModuleChange('admin', ROUTE_LINKS.GRIDS);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Grids"
+ subLabel="Configure spatial grids"
+ isActive={pathname.startsWith(ROUTE_LINKS.GRIDS)}
+ />
+ )}
+
+ {vertexConfig.features.shipping && (
+ {
+ navigateWithShimmer(ROUTE_LINKS.ADMIN_SHIPPING, () => {
+ handleModuleChange('admin', ROUTE_LINKS.ADMIN_SHIPPING);
+ setIsDropdownOpen(false);
+ });
+ }}
+ label="Shipping"
+ subLabel="Track device logistics"
+ isActive={pathname.startsWith(ROUTE_LINKS.ADMIN_SHIPPING)}
+ />
+ )}
+
+
+ )}
+
+ {/* Recently Visited - visible to ALL users */}
+ {visitedPages.length > 0 && (
+
+
+ {
+ if (recentTimeoutRef.current) clearTimeout(recentTimeoutRef.current);
+ setIsRecentOpen(true);
+ }}
+ onMouseLeave={() => {
+ recentTimeoutRef.current = setTimeout(() => setIsRecentOpen(false), 200);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setIsRecentOpen(!isRecentOpen);
+ }
+ }}
+ >
+ { e?.preventDefault(); }}
+ />
+
+
+ {
+ if (recentTimeoutRef.current) clearTimeout(recentTimeoutRef.current);
+ }}
+ onMouseLeave={() => {
+ recentTimeoutRef.current = setTimeout(() => setIsRecentOpen(false), 200);
+ }}
+ >
+
+ Recently Visited
+
+
+
+ {visitedPages.map((page, index) => (
+ {
+ navigateWithShimmer(page.href, () => {
+ // Detect module from href using route constants
+ if (page.href.startsWith('/devices')) {
+ handleModuleChange('devices', page.href);
+ } else if (page.href.startsWith('/admin')) {
+ handleModuleChange('admin', page.href);
+ } else {
+ router.push(page.href);
+ }
+ setIsRecentOpen(false);
+ });
+ }}
+ className={cn(
+ "flex flex-col items-start gap-1 p-3 cursor-pointer",
+ pathname === page.href && "bg-primary/10 text-primary"
+ )}
+ >
+ {page.label}
+
+ ))}
+
+
+ )}
+
+
+ >
+ );
+};
+
+export default PrimarySidebar;
diff --git a/src/vertex-template/components/layout/secondary-sidebar.tsx b/src/vertex-template/components/layout/secondary-sidebar.tsx
new file mode 100644
index 0000000000..677f7d9dc2
--- /dev/null
+++ b/src/vertex-template/components/layout/secondary-sidebar.tsx
@@ -0,0 +1,293 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+import { ChevronRight, ChevronLeft } from 'lucide-react';
+import {
+ AqHomeSmile,
+ AqCpuChip01,
+ AqMonitor,
+ AqMarkerPin01,
+ AqPackagePlus,
+ AqCollocation,
+ AqBezierCurve02,
+ AqFileQuestion02,
+} from '@airqo/icons-react';
+import { Button } from '@/components/ui/button';
+import { useUserContext } from '@/core/hooks/useUserContext';
+import { usePathname } from 'next/navigation';
+import { ROUTE_LINKS } from '@/core/routes';
+import Card from '../shared/card/CardWrapper';
+import { NavItem } from './NavItem';
+
+interface SecondarySidebarProps {
+ isCollapsed: boolean;
+ toggleSidebar: () => void;
+ activeModule: string;
+ onNavigate?: () => void;
+}
+
+const styles = {
+ scrollbar: 'scrollbar-thumb-gray-300 scrollbar-track-gray-100',
+};
+
+const SidebarSectionHeading = ({
+ children,
+ isCollapsed,
+}: {
+ children: React.ReactNode;
+ isCollapsed: boolean;
+}) =>
+ !isCollapsed ? (
+
+ {children}
+
+ ) : null;
+
+const SecondarySidebar: React.FC = ({
+ isCollapsed,
+ toggleSidebar,
+ activeModule,
+ onNavigate,
+}) => {
+ const { getContextPermissions, isExternalOrg } =
+ useUserContext();
+ const contextPermissions = getContextPermissions();
+ const pathname = usePathname();
+
+ const [platform, setPlatform] = React.useState<'win' | 'linux' | 'other' | null>(null);
+ const [isElectron, setIsElectron] = React.useState(false);
+
+ React.useEffect(() => {
+ const userAgent = window.navigator.userAgent.toLowerCase();
+ setIsElectron(userAgent.includes('electron'));
+ if (userAgent.includes('win')) {
+ setPlatform('win');
+ } else if (userAgent.includes('linux')) {
+ setPlatform('linux');
+ } else {
+ setPlatform('other');
+ }
+ }, []);
+
+ return (
+
+
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ Download for Windows
+
+
+ )}
+ >
+ {/* Device Management Module - Personal devices for all users */}
+ {activeModule === 'devices' && (
+ <>
+ {!isExternalOrg ? (
+ /* Personal Scope View (Default) */
+ <>
+
+
+ Personal assets
+
+
+
+
+ Data Access & Visibility
+
+
+ >
+ ) : (
+ /* Organization Scope View (External & Internal) */
+ <>
+
+
+ Organization Assets
+
+
+
+
+
+ Data Access & Visibility
+
+
+ >
+ )}
+ >
+ )}
+
+ {/* Platform Admin Module - Consolidated Administrative Panel */}
+ {activeModule === 'admin' && (
+ <>
+
+ Administrative Panel
+
+
+ {/* Network Management Dropdown */}
+ {/* Network Management - Flat Item */}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default SecondarySidebar;
diff --git a/src/vertex-template/components/layout/sub-menu.tsx b/src/vertex-template/components/layout/sub-menu.tsx
new file mode 100644
index 0000000000..79e237533f
--- /dev/null
+++ b/src/vertex-template/components/layout/sub-menu.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import React, { useState } from "react";
+import { ChevronDown } from "lucide-react";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+
+interface SubMenuProps {
+ label: string;
+ icon: React.ElementType;
+ href: string;
+ isCollapsed: boolean;
+ children: React.ReactNode;
+}
+
+const SubMenu: React.FC = ({ label, icon: Icon, isCollapsed, children }) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const toggleSubMenu = () => {
+ setIsOpen(!isOpen);
+ };
+
+ const content = (
+
+
+
+ {label}
+
+ {!isCollapsed && (
+
+ )}
+
+ );
+
+ return (
+
+
+
+ {content}
+ {isCollapsed && (
+ {label}
+ )}
+
+
+
+ {!isCollapsed && isOpen && (
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default SubMenu;
\ No newline at end of file
diff --git a/src/vertex-template/components/layout/topbar.tsx b/src/vertex-template/components/layout/topbar.tsx
new file mode 100644
index 0000000000..2ea8b2aadb
--- /dev/null
+++ b/src/vertex-template/components/layout/topbar.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import type React from 'react';
+import { useState, useEffect, useCallback } from 'react';
+import { Moon, Sun } from 'lucide-react';
+import { AqHelpCircle, AqMenu01 } from '@airqo/icons-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useAppSelector } from '@/core/redux/hooks';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { useRouter, usePathname } from 'next/navigation';
+import OrganizationPicker from '../features/org-picker/organization-picker';
+import Image from 'next/image';
+import Card from '../shared/card/CardWrapper';
+import ReusableButton from '../shared/button/ReusableButton';
+import { useLogout } from '@/core/hooks/useLogout';
+import AppDropdown from './AppDropdown';
+import { openFeedbackDialog } from '../features/feedback/feedback-dialog';
+import { useSession } from 'next-auth/react';
+import type { UserDetails } from '@/app/types/users';
+import { useTheme } from "next-themes";
+import { vertexConfig } from '@/vertex.config';
+
+interface TopbarProps {
+ onMenuClick: () => void;
+}
+
+const LogoRaw = vertexConfig.org.logo;
+
+const Topbar: React.FC = ({ onMenuClick }) => {
+ const { setTheme, resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ const currentUser = useAppSelector(state => state.user.userDetails);
+ const logout = useLogout();
+ const router = useRouter();
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const isAdminMode = pathname?.startsWith('/admin');
+
+ const user = currentUser || (session?.user as unknown as Partial & { image?: string | null });
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const toggleDarkMode = () => {
+ setTheme(resolvedTheme === "dark" ? "light" : "dark");
+ };
+
+ const handleLogoClick = useCallback(() => {
+ router.push('/home');
+ }, [router]);
+
+ const LogoComponent = useCallback(
+ ({ className = '', buttonProps = {} }) => (
+
+
+
+ ),
+ [handleLogoClick]
+ );
+
+ // Get user initials for avatar fallback
+ const getInitials = () => {
+ if (!user) return 'U';
+ const first = user.firstName || '';
+ const last = user.lastName || '';
+ return `${first.charAt(0) || ''}${last.charAt(0) || ''}`.toUpperCase();
+ };
+
+ // Format user name
+ const getUserName = () => {
+ if (!user) return 'User';
+ return (
+ `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
+ user.userName ||
+ user.email ||
+ 'User'
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
{vertexConfig.org.name}
+ {isAdminMode && (
+
+ Administrator
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getInitials()}
+
+
+
+
+
+
+
+
+
+ {getInitials()}
+
+
+
+
+ {getUserName().length > 18
+ ? getUserName().slice(0, 18) + '...'
+ : getUserName()}
+
+
+ {user?.email || user?.userName}
+
+
+
+
+
+
+
+ {mounted && resolvedTheme === 'dark' ? (
+
+ ) : (
+
+ )}
+ {mounted && resolvedTheme === 'dark' ? 'Light Mode' : 'Dark Mode'}
+
+
+
+ Log out
+
+
+
+
+
+
+
+ );
+};
+
+export default Topbar;
diff --git a/src/vertex-template/components/shared/ErrorBoundary.tsx b/src/vertex-template/components/shared/ErrorBoundary.tsx
new file mode 100644
index 0000000000..f7488c91dc
--- /dev/null
+++ b/src/vertex-template/components/shared/ErrorBoundary.tsx
@@ -0,0 +1,77 @@
+"use client"
+
+import React, { Component, ErrorInfo, ReactNode } from "react"
+import { AlertTriangle } from "lucide-react"
+import ReusableDialog from "./dialog/ReusableDialog"
+
+interface Props {
+ children: ReactNode
+}
+
+interface State {
+ hasError: boolean
+ error?: Error
+}
+
+class ErrorBoundary extends Component {
+ public state: State = {
+ hasError: false,
+ error: undefined,
+ }
+
+ public static getDerivedStateFromError(error: Error): Partial {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true, error }
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ // You can log the error to an error reporting service here
+ console.error("Uncaught error:", error, errorInfo)
+ }
+
+ private handleReload = () => {
+ window.location.reload()
+ }
+
+ render() {
+ if (this.state.hasError) {
+ const isDevelopment = process.env.NODE_ENV === "development"
+
+ return (
+
+ {isDevelopment && this.state.error && (
+
+
+ Developer Information (This is only visible in development mode)
+
+
+ {this.state.error.toString()}
+ {"\n\n"}
+ {this.state.error.stack}
+
+
+ )}
+
+ )
+ }
+
+ return this.props.children
+ }
+}
+
+export default ErrorBoundary
\ No newline at end of file
diff --git a/src/vertex-template/components/shared/button/ReusableButton.tsx b/src/vertex-template/components/shared/button/ReusableButton.tsx
new file mode 100644
index 0000000000..5b2439231f
--- /dev/null
+++ b/src/vertex-template/components/shared/button/ReusableButton.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import React from "react"
+import { cn } from "@/lib/utils"
+import PermissionTooltip from "@/components/ui/permission-tooltip"
+
+interface ReusableButtonBaseProps {
+ variant?: "filled" | "outlined" | "text" | "disabled"
+ padding?: string
+ paddingStyles?: string
+ className?: string
+ disabled?: boolean
+ dataTestId?: string
+ Icon?: React.ComponentType<{ className?: string }>
+ iconClassName?: string
+ children?: React.ReactNode
+ showTextOnMobile?: boolean
+ loading?: boolean
+ permission?: string
+}
+
+type ButtonNativeProps = ReusableButtonBaseProps &
+ Omit, keyof ReusableButtonBaseProps> & {
+ path?: never
+ }
+
+type AnchorNativeProps = ReusableButtonBaseProps &
+ Omit, keyof ReusableButtonBaseProps> & {
+ path: string
+ }
+
+type ReusableButtonProps = ButtonNativeProps | AnchorNativeProps
+
+const ReusableButton = React.forwardRef(
+ (
+ {
+ variant = "filled",
+ padding = "py-2 px-4",
+ paddingStyles,
+ className,
+ disabled = false,
+ Icon,
+ iconClassName,
+ children,
+ showTextOnMobile = false,
+ loading,
+ dataTestId,
+ permission,
+ ...rest
+ },
+ ref,
+ ) => {
+ // Base styles
+ const base = "flex items-center justify-center rounded-lg transition transform active:scale-95 duration-200"
+ const variantMap = {
+ filled: cn(
+ "bg-primary",
+ "hover:bg-primary/80",
+ "text-white",
+ "border border-transparent",
+ "shadow-sm hover:shadow-lg",
+ "focus:ring-2 focus:ring-primary focus:ring-opacity-50",
+ ),
+ outlined: cn(
+ "bg-transparent",
+ "border border-primary",
+ "text-primary",
+ "hover:bg-primary",
+ "hover:text-white",
+ "focus:ring-2 focus:ring-primary focus:ring-opacity-50",
+ ),
+ text: cn(
+ "bg-transparent",
+ "text-primary",
+ "hover:bg-primary/10",
+ "focus:ring-2 focus:ring-primary focus:ring-opacity-50",
+ ),
+ disabled: cn("bg-gray-300 dark:bg-gray-600", "text-gray-500 dark:text-gray-400", "border border-transparent"),
+ }
+ const activeVariant = disabled ? "disabled" : variant
+ const variantStyles = variantMap[activeVariant] || variantMap.filled
+ const disabledStyles = disabled && "cursor-not-allowed opacity-50"
+
+ const finalPadding = paddingStyles || padding
+
+ const loadingStyles = loading ? "bg-gray-400 !text-gray-900 !border-gray-400" : ""
+
+ const btnClass = cn(base, finalPadding, variantStyles, disabledStyles, loadingStyles, className)
+
+ const iconMargin = "mr-2"
+
+ const ButtonContent = (
+ <>
+ {Icon && }
+ {children}
+ >
+ )
+
+ if ("path" in rest) {
+ const { path, ...anchorProps } = rest as AnchorNativeProps
+ const anchorButton = (disabled || loading) ? (
+ }
+ className={btnClass}
+ data-testid={dataTestId}
+ aria-disabled="true"
+ >
+ {ButtonContent}
+
+ ) : (
+ }
+ href={path}
+ className={btnClass}
+ data-testid={dataTestId}
+ aria-disabled={false}
+ {...anchorProps}>
+ {ButtonContent}
+ )
+
+ if (disabled && permission) {
+ return {anchorButton}
+ }
+ return anchorButton
+ }
+
+ const { type = "button", ...buttonProps } = rest as ButtonNativeProps
+ const button = (
+ }
+ type={type}
+ className={btnClass}
+ data-testid={dataTestId}
+ disabled={disabled || loading}
+ aria-busy={!!loading}
+ {...buttonProps}
+ >
+ {ButtonContent}
+
+ )
+
+ if (disabled && permission) {
+ return {button}
+ }
+ return button
+ },
+)
+
+ReusableButton.displayName = "ReusableButton"
+
+export default ReusableButton
diff --git a/src/vertex-template/components/shared/card/CardWrapper.tsx b/src/vertex-template/components/shared/card/CardWrapper.tsx
new file mode 100644
index 0000000000..0819111320
--- /dev/null
+++ b/src/vertex-template/components/shared/card/CardWrapper.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import React, { forwardRef, ReactNode } from 'react';
+import clsx from 'clsx';
+
+interface CardProps extends Omit, 'title'> {
+ children?: ReactNode;
+ className?: string;
+ contentClassName?: string;
+ bordered?: boolean;
+ borderColor?: string;
+ rounded?: boolean;
+ radius?: string;
+ background?: string;
+ shadow?: string;
+ padding?: string;
+ width?: string;
+ height?: string;
+ overflow?: boolean;
+ overflowType?: 'auto' | 'scroll' | 'hidden' | 'visible';
+ header?: ReactNode;
+ footer?: ReactNode;
+ leftContent?: ReactNode;
+ rightContent?: ReactNode;
+ badge?: ReactNode;
+ icon?: ReactNode;
+ cardTitle?: ReactNode;
+ subtitle?: ReactNode;
+ headerProps?: React.HTMLAttributes;
+ footerProps?: React.HTMLAttributes;
+ onClick?: () => void;
+ active?: boolean;
+ hoverable?: boolean;
+ disabled?: boolean;
+ testId?: string;
+ skin?: 'default' | 'bordered';
+ isSemiDarkEnabled?: boolean;
+}
+
+const Card = forwardRef(
+ (
+ {
+ children,
+ className = '',
+ contentClassName = '',
+ bordered,
+ borderColor = 'border-gray-200 dark:border-gray-700',
+ rounded = true,
+ radius = 'rounded-lg',
+ background = 'bg-white dark:bg-[#1d1f20]',
+ shadow = '',
+ padding = 'p-4',
+ width = 'w-full',
+ height = 'h-auto',
+ overflow = false,
+ overflowType = 'auto',
+ header,
+ footer,
+ leftContent,
+ rightContent,
+ badge,
+ icon,
+ cardTitle,
+ subtitle,
+ headerProps = {},
+ footerProps = {},
+ onClick,
+ active = false,
+ hoverable = false,
+ disabled = false,
+ testId,
+ skin = 'bordered',
+ isSemiDarkEnabled = false,
+ ...rest
+ },
+ ref
+ ) => {
+
+ const shouldBeBordered =
+ bordered !== undefined ? bordered : skin === 'bordered';
+
+ const appliedShadow = shadow || (skin === 'default' ? 'shadow' : '');
+
+ const isSidebarCard = className?.includes('sidebar') || false;
+ const shouldApplySemiDark = isSemiDarkEnabled && isSidebarCard;
+
+ const cardClasses = clsx(
+ 'flex flex-col',
+ width,
+ height,
+ shouldApplySemiDark ? 'bg-gray-800 text-white' : background,
+ rounded && (radius || 'rounded-xl'),
+ shouldBeBordered && ['border', borderColor],
+ appliedShadow,
+ onClick && 'cursor-pointer',
+ active && 'ring-2 ring-blue-500',
+ hoverable && 'hover:shadow-md transition-shadow duration-200',
+ disabled && 'opacity-60 pointer-events-none',
+ !shouldApplySemiDark && 'text-gray-900 dark:text-white',
+ className
+ );
+
+ const contentClasses = clsx(
+ padding,
+ overflow && [
+ `overflow-y-${overflowType}`,
+ 'scrollbar-thin scrollbar-thumb-gray-400 dark:scrollbar-thumb-gray-600 scrollbar-track-gray-200 dark:scrollbar-track-gray-700',
+ ],
+ contentClassName
+ );
+
+ const headerClasses = clsx(
+ 'flex items-center justify-between',
+ padding,
+ headerProps.className || ''
+ );
+
+ const footerClasses = clsx(
+ 'mt-auto',
+ padding,
+ footerProps.className || ''
+ );
+
+ const defaultHeader =
+ cardTitle || subtitle || icon || badge ? (
+
+
+ {icon &&
{icon}
}
+ {(cardTitle || subtitle) && (
+
+ {cardTitle && (
+
+ {cardTitle}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ )}
+
+ {badge &&
{badge}
}
+
+ ) : null;
+
+ return (
+
+ {(header || defaultHeader) && (
+
+ {header || defaultHeader}
+
+ )}
+
+ {leftContent || rightContent ? (
+
+ {leftContent &&
{leftContent}
}
+
+ {children}
+
+ {rightContent && (
+
{rightContent}
+ )}
+
+ ) : (
+
{children}
+ )}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+ }
+);
+
+Card.displayName = 'Card';
+
+export default Card;
diff --git a/src/vertex-template/components/shared/charts/Bar.tsx b/src/vertex-template/components/shared/charts/Bar.tsx
new file mode 100644
index 0000000000..66fc9a74ef
--- /dev/null
+++ b/src/vertex-template/components/shared/charts/Bar.tsx
@@ -0,0 +1,78 @@
+"use client"
+
+import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
+
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+const chartData = [
+ { month: "January", good: 186, moderate: 80, uhfsg: 200, unhealthy: 100, veryunhealthy: 150, hazadrous: 50 },
+ { month: "February", good: 305, moderate: 200, uhfsg: 100, unhealthy: 80, veryunhealthy: 50, hazadrous: 40 },
+ { month: "March", good: 237, moderate: 120, uhfsg: 300, unhealthy: 150, veryunhealthy: 60, hazadrous: 100 },
+ { month: "April", good: 73, moderate: 190, uhfsg: 350, unhealthy: 100, veryunhealthy: 50, hazadrous: 200 },
+ { month: "May", good: 209, moderate: 130, uhfsg: 170, unhealthy: 46, veryunhealthy: 70, hazadrous: 30 },
+ { month: "June", good: 214, moderate: 140, uhfsg: 200, unhealthy: 100, veryunhealthy: 100, hazadrous: 50 },
+]
+
+const chartConfig = {
+ good: {
+ label: "good",
+ color: "hsl(var(--chart-6))",
+ },
+ moderate: {
+ label: "moderate",
+ color: "hsl(var(--chart-7))",
+ },
+ uhfsg: {
+ label: "uhfsg",
+ color: "hsl(var(--chart-6))",
+ },
+ unhealthy: {
+ label: "unhealthy",
+ color: "hsl(var(--chart-7))",
+ },
+ veryunhealty: {
+ label: "veryunhealty",
+ color: "hsl(var(--chart-6))",
+ },
+ hazadrous: {
+ label: "hazadrous",
+ color: "hsl(var(--chart-6))",
+ },
+} satisfies ChartConfig
+
+export function BarCharts() {
+ return (
+
+
+
+
+ value.slice(0, 3)}
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/vertex-template/components/shared/charts/Line.tsx b/src/vertex-template/components/shared/charts/Line.tsx
new file mode 100644
index 0000000000..580eac7f1d
--- /dev/null
+++ b/src/vertex-template/components/shared/charts/Line.tsx
@@ -0,0 +1,124 @@
+"use client"
+
+import { CartesianGrid, Line, LineChart, XAxis } from "recharts"
+
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+const chartData = [
+ { month: "January", good: 186, moderate: 80, uhfsg: 200, unhealthy: 100, veryunhealthy: 150, hazadrous: 50 },
+ { month: "February", good: 305, moderate: 200, uhfsg: 100, unhealthy: 80, veryunhealthy: 50, hazadrous: 40 },
+ { month: "March", good: 237, moderate: 120, uhfsg: 300, unhealthy: 150, veryunhealthy: 60, hazadrous: 100 },
+ { month: "April", good: 73, moderate: 190, uhfsg: 350, unhealthy: 100, veryunhealthy: 50, hazadrous: 200 },
+ { month: "May", good: 209, moderate: 130, uhfsg: 170, unhealthy: 46, veryunhealthy: 70, hazadrous: 30 },
+ { month: "June", good: 214, moderate: 140, uhfsg: 200, unhealthy: 100, veryunhealthy: 100, hazadrous: 50 },
+ { month: "July", good: 186, moderate: 80, uhfsg: 200, unhealthy: 40, veryunhealthy: 70, hazadrous: 150 },
+ { month: "August", good: 305, moderate: 200, uhfsg: 100, unhealthy: 350, veryunhealthy: 50, hazadrous: 100 },
+ { month: "September", good: 237, moderate: 120, uhfsg: 230, unhealthy: 96, veryunhealthy: 89, hazadrous: 200 },
+ { month: "October", good: 73, moderate: 190, uhfsg: 350, unhealthy: 100, veryunhealthy: 250, hazadrous: 95 },
+ { month: "November", good: 209, moderate: 130, uhfsg: 76, unhealthy: 40, veryunhealthy: 200, hazadrous: 50 },
+ { month: "December", good: 214, moderate: 140, uhfsg: 40, unhealthy: 200, veryunhealthy: 50, hazadrous: 170 },
+]
+
+const chartConfig = {
+ good: {
+ label: "good",
+ color: "hsl(var(--chart-6))",
+ },
+ moderate: {
+ label: "moderate",
+ color: "hsl(var(--chart-7))",
+ },
+ uhfsg: {
+ label: "uhfsg",
+ color: "hsl(var(--chart-6))",
+ },
+ unhealthy: {
+ label: "unhealthy",
+ color: "hsl(var(--chart-7))",
+ },
+ veryunhealty: {
+ label: "veryunhealty",
+ color: "hsl(var(--chart-6))",
+ },
+ hazadrous: {
+ label: "hazadrous",
+ color: "hsl(var(--chart-6))",
+ },
+} satisfies ChartConfig
+
+export function LineCharts() {
+ return (
+
+
+
+
+ value.slice(0, 3)}
+ />
+ } />
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/vertex-template/components/shared/dialog/ReusableDialog.tsx b/src/vertex-template/components/shared/dialog/ReusableDialog.tsx
new file mode 100644
index 0000000000..ba701fa1a5
--- /dev/null
+++ b/src/vertex-template/components/shared/dialog/ReusableDialog.tsx
@@ -0,0 +1,314 @@
+"use client"
+
+import type React from "react"
+import { useEffect, useRef } from "react"
+import { AnimatePresence, motion } from "framer-motion"
+import ReusableButton from "@/components/shared/button/ReusableButton"
+import { X } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { BannerSlot, useBanner } from "@/context/banner-context"
+
+interface ReusableDialogProps {
+ // Core props
+ isOpen: boolean
+ onClose: () => void
+ children: React.ReactNode
+
+ // Header props
+ title?: string
+ subtitle?: string
+ icon?: React.ComponentType<{ className?: string }>
+ iconColor?: string
+ iconBgColor?: string
+ showCloseButton?: boolean
+ customHeader?: React.ReactNode
+
+ // Content props
+ maxHeight?: string
+ contentClassName?: string
+
+ // Footer props
+ showFooter?: boolean
+ primaryAction?: {
+ label: string
+ onClick: () => void
+ disabled?: boolean
+ className?: string
+ }
+ secondaryAction?: {
+ label: string
+ onClick: () => void
+ disabled?: boolean
+ variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
+ className?: string
+ }
+ customFooter?: React.ReactNode
+
+ // Modal props
+ size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl"
+ preventBackdropClose?: boolean
+ className?: string
+ contentAreaClassName?: string
+ zIndex?: number
+
+ // Accessibility
+ ariaLabel?: string
+ ariaDescribedBy?: string
+}
+
+const ReusableDialog: React.FC = ({
+ // Core props
+ isOpen,
+ onClose,
+ children,
+
+ // Header props
+ title,
+ subtitle,
+ icon: Icon,
+ iconColor = "text-blue-600",
+ iconBgColor = "bg-blue-100",
+ showCloseButton = true,
+ customHeader,
+
+ // Content props
+ maxHeight = "max-h-96",
+ contentClassName = "",
+
+ // Footer props
+ showFooter = true,
+ primaryAction,
+ secondaryAction,
+ customFooter,
+
+ // Modal props
+ size = "lg",
+ preventBackdropClose = false,
+ className = "",
+ contentAreaClassName = "",
+ zIndex = 999,
+
+ // Accessibility
+ ariaLabel,
+ ariaDescribedBy,
+}) => {
+ const dialogRef = useRef(null)
+ const previousActiveElement = useRef(null)
+ const { hideBanner } = useBanner()
+
+
+
+ // Focus management
+ useEffect(() => {
+ if (isOpen) {
+ previousActiveElement.current = document.activeElement
+ // Use timeout to ensure dialog is rendered before focusing
+ setTimeout(() => {
+ dialogRef.current?.focus()
+ }, 100)
+ } else {
+ ;(previousActiveElement.current as HTMLElement)?.focus()
+ }
+ }, [isOpen])
+
+ const wasOpenRef = useRef(isOpen)
+
+ // Clear any active banner when the dialog transitions from open to closed to prevent state leakage
+ useEffect(() => {
+ if (wasOpenRef.current && !isOpen) {
+ hideBanner()
+ }
+ wasOpenRef.current = isOpen
+ }, [isOpen, hideBanner])
+
+ // Escape key handler and body scroll lock
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isOpen && !preventBackdropClose) {
+ onClose()
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener("keydown", handleEscape)
+ document.body.style.overflow = "hidden"
+ }
+
+ return () => {
+ document.removeEventListener("keydown", handleEscape)
+ document.body.style.overflow = ""
+ }
+ }, [isOpen, onClose, preventBackdropClose])
+
+ // Size mapping for dialog widths
+ const sizeMap = {
+ xs: "max-w-xs",
+ sm: "max-w-sm",
+ md: "max-w-md",
+ lg: "max-w-lg",
+ xl: "max-w-xl",
+ "2xl": "max-w-2xl",
+ "3xl": "max-w-3xl",
+ "4xl": "max-w-4xl",
+ "5xl": "max-w-5xl",
+ "6xl": "max-w-6xl",
+ "7xl": "max-w-7xl",
+ }
+
+ const dialogWidth = sizeMap[size] || "max-w-lg"
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget && !preventBackdropClose) {
+ onClose()
+ }
+ }
+
+ // Create header content
+ const createHeaderContent = () => {
+ if (customHeader) {
+ return customHeader
+ }
+
+ if (!title && !Icon && !showCloseButton) {
+ return null
+ }
+
+ return (
+
+
+ {Icon && (
+
+
+
+ )}
+ {(title || subtitle) && (
+
+ {title &&
{title} }
+ {subtitle &&
{subtitle}
}
+
+ )}
+
+ {showCloseButton && (
+
+
+
+ )}
+
+ )
+ }
+
+ // Create footer content
+ const createFooterContent = () => {
+ if (customFooter) {
+ return customFooter
+ }
+
+ if (!showFooter || (!primaryAction && !secondaryAction)) {
+ return null
+ }
+
+ return (
+
+ {secondaryAction && (() => {
+ const shadcnVariant = secondaryAction.variant || "outline"
+ let reusableVariant: "filled" | "outlined" | "text"
+ switch (shadcnVariant) {
+ case "outline":
+ case "secondary":
+ reusableVariant = "outlined"
+ break
+ case "ghost":
+ case "link":
+ reusableVariant = "text"
+ break
+ default:
+ reusableVariant = "filled"
+ }
+ return (
+
+ {secondaryAction.label}
+
+ )
+ })()}
+ {primaryAction && (
+
+ {primaryAction.label}
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Dialog Container */}
+
+ {/* Dialog */}
+ e.stopPropagation()}
+ >
+ {/* Header */}
+ {createHeaderContent()}
+
+ {/* Banner Slot */}
+
+
+ {/* Content */}
+
+
+ {/* Footer */}
+ {createFooterContent()}
+
+
+ >
+ )}
+
+ )
+}
+
+export default ReusableDialog
diff --git a/src/vertex-template/components/shared/fileupload/ReusableFileUpload.tsx b/src/vertex-template/components/shared/fileupload/ReusableFileUpload.tsx
new file mode 100644
index 0000000000..10f879b3e4
--- /dev/null
+++ b/src/vertex-template/components/shared/fileupload/ReusableFileUpload.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import React, { useId } from "react";
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+import { FileUp } from "lucide-react";
+
+interface ReusableFileUploadProps {
+ id?: string;
+ label?: string;
+ accept?: string;
+ file?: File | null;
+ onChange?: (file: File | null) => void;
+ placeholder?: string;
+ description?: string;
+ error?: string;
+ className?: string;
+ containerClassName?: string;
+}
+
+const ReusableFileUpload: React.FC = ({
+ id,
+ label,
+ accept,
+ file,
+ onChange,
+ placeholder = "Upload file",
+ description,
+ error,
+ className,
+ containerClassName,
+}) => {
+ const generatedId = useId();
+ const inputId = id ?? `fileupload-${generatedId}`;
+ const errorId = `${inputId}-error`;
+ const descId = `${inputId}-desc`;
+
+ const commonClasses =
+ "flex items-center justify-center w-full rounded-xl border bg-white px-4 py-2.5 text-sm text-gray-700 transition-colors duration-150 ease-in-out dark:bg-gray-800 dark:text-gray-200 hover:border-primary/50 hover:bg-slate-50 dark:hover:bg-gray-700 pointer-events-none";
+
+ const errorClasses = error
+ ? "border-red-500 shadow-[0_0_0_1px_red]"
+ : "border-gray-300 dark:border-gray-600 shadow-sm";
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
{
+ const nextFile = e.target.files?.[0] ?? null;
+ onChange?.(nextFile);
+ e.currentTarget.value = "";
+ }}
+ aria-invalid={!!error}
+ aria-describedby={
+ error ? errorId : description ? descId : undefined
+ }
+ />
+
+
+
+ {file ? file.name : placeholder}
+
+
+
+ {error && (
+
+ )}
+ {!error && description && (
+
+ {description}
+
+ )}
+
+ );
+};
+
+export default ReusableFileUpload;
diff --git a/src/vertex-template/components/shared/inputfield/ReusableInputField.tsx b/src/vertex-template/components/shared/inputfield/ReusableInputField.tsx
new file mode 100644
index 0000000000..5540e78999
--- /dev/null
+++ b/src/vertex-template/components/shared/inputfield/ReusableInputField.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import type React from "react"
+import { useId, useState } from "react"
+import { cn } from "@/lib/utils"
+import { AqCopy01, AqEye, AqEyeOff } from "@airqo/icons-react"
+import ReusableToast from "../toast/ReusableToast"
+
+interface CommonProps {
+ label?: string
+ error?: string
+ containerClassName?: string
+ primaryColor?: string
+ description?: string
+ className?: string
+ required?: boolean
+ disabled?: boolean
+ readOnly?: boolean
+ showCopyButton?: boolean
+ customActionIcon?: React.ComponentType<{ className?: string }>
+ onCustomAction?: () => void
+ customActionLabel?: string
+}
+
+type InputProps = CommonProps &
+ React.InputHTMLAttributes & {
+ as?: "input"
+ }
+
+type TextareaProps = CommonProps &
+ React.TextareaHTMLAttributes & {
+ as: "textarea"
+ }
+
+type ReusableInputFieldProps = InputProps | TextareaProps
+
+const ReusableInputField: React.FC = ({
+ label,
+ error,
+ containerClassName = "",
+ primaryColor = "",
+ className = "",
+ required = false,
+ disabled = false,
+ description,
+ readOnly,
+ showCopyButton,
+ customActionIcon: CustomActionIcon,
+ onCustomAction,
+ customActionLabel,
+ ...props
+}) => {
+ const [showPassword, setShowPassword] = useState(false)
+ const Component = props.as === "textarea" ? "textarea" : "input"
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { as: _, ...inputProps } = props
+ const generatedId = useId()
+ const inputId = (inputProps as { id?: string }).id ?? `rif-${generatedId}`
+ const errorId = `${inputId}-error`
+ const descId = `${inputId}-desc`
+
+ const handleCopy = async () => {
+ const valueToCopy = (inputProps as { value?: string | number }).value
+ if (valueToCopy !== undefined && valueToCopy !== null) {
+ try {
+ await navigator.clipboard?.writeText(String(valueToCopy))
+ ReusableToast({ message: "Copied", type: "SUCCESS" })
+ } catch (err) {
+ ReusableToast({ message: `Failed to copy: ${String(err)}`, type: "ERROR" })
+ }
+ }
+ }
+
+ const commonClasses =
+ "w-full rounded-xl border bg-white px-4 py-2.5 text-sm text-gray-700 placeholder-gray-400 border-gray-300 transition-colors duration-150 ease-in-out dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 hover:border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-700 dark:disabled:text-gray-400"
+
+ const isPasswordInput = props.as !== "textarea" && (inputProps as React.InputHTMLAttributes).type === "password"
+ const canShowCopyButton = showCopyButton && readOnly
+ const showPasswordToggle = isPasswordInput && !readOnly && !showCopyButton
+ const showCustomActionButton = CustomActionIcon && onCustomAction
+
+ const paddingRightClass = (() => {
+ const count = [canShowCopyButton, showPasswordToggle, showCustomActionButton].filter(Boolean).length;
+ switch (count) {
+ case 1: return "pr-10";
+ case 2: return "pr-18";
+ case 3: return "pr-26";
+ default: return "";
+ }
+ })();
+
+ return (
+
+ {label && (
+
+ {label}
+ {required && !readOnly && (
+
+ *
+
+ )}
+
+ )}
+
+
&
+ React.TextareaHTMLAttributes)}
+ type={isPasswordInput ? (showPassword ? "text" : "password") : (inputProps as React.InputHTMLAttributes).type}
+ />
+
+ {showCustomActionButton && (
+
+
+
+ )}
+ {canShowCopyButton && (
+
+
+
+ )}
+ {showPasswordToggle && (
+
setShowPassword((prev) => !prev)}
+ className="text-gray-500 hover:text-primary focus:outline-none"
+ aria-label={showPassword ? "Hide password" : "Show password"}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {error && !readOnly && (
+
+ )}
+ {!error && description &&
{description}
}
+
+ )
+}
+
+export default ReusableInputField
diff --git a/src/vertex-template/components/shared/select/ReusableSelectInput.tsx b/src/vertex-template/components/shared/select/ReusableSelectInput.tsx
new file mode 100644
index 0000000000..835deb5034
--- /dev/null
+++ b/src/vertex-template/components/shared/select/ReusableSelectInput.tsx
@@ -0,0 +1,311 @@
+"use client"
+
+import React, { useState, useEffect, useRef, useMemo, useCallback, useId } from "react"
+import { cn } from "@/lib/utils"
+
+interface SelectOption {
+ value: string
+ label: string
+ disabled?: boolean
+}
+
+interface ReusableSelectInputProps extends Omit, "onChange"> {
+ label?: string
+ error?: string
+ description?: string
+ containerClassName?: string
+ className?: string
+ listClassName?: string
+ required?: boolean
+ disabled?: boolean
+ children: React.ReactNode
+ onChange?: (event: { target: { value: string; name?: string; id?: string } }) => void
+ value?: string
+ placeholder?: string
+ maxHeight?: number
+}
+
+const ReusableSelectInput: React.FC = ({
+ label,
+ error,
+ description,
+ containerClassName = "",
+ className = "",
+ listClassName = "",
+ required = false,
+ disabled = false,
+ children,
+ onChange,
+ value,
+ placeholder = "Select an option",
+ maxHeight = 240,
+ ...rest
+}) => {
+ const [open, setOpen] = useState(false)
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
+ const containerRef = useRef(null)
+ const listRef = useRef(null)
+ const buttonRef = useRef(null)
+
+ // Convert children (option elements) into a consumable items array
+ const items = useMemo(() => {
+ return (
+ React.Children.map(children, (child) => {
+ if (React.isValidElement(child) && child.type === "option") {
+ return {
+ value: child.props.value,
+ label: child.props.children,
+ disabled: child.props.disabled,
+ }
+ }
+ return null
+ })?.filter(Boolean) || []
+ )
+ }, [children])
+
+ // Find the currently selected item based on the 'value' prop
+ const selectedItem = useMemo(() => {
+ return items.find((item) => item.value === value)
+ }, [items, value])
+
+ // Handle item selection
+ const handleSelect = useCallback(
+ (item: SelectOption) => {
+ if (disabled || item.disabled) return
+
+ const syntheticEvent = {
+ target: {
+ value: item.value,
+ name: rest.name,
+ id: rest.id,
+ },
+ }
+ onChange?.(syntheticEvent)
+ setOpen(false)
+ setHighlightedIndex(-1)
+
+ // Return focus to the button after selection
+ buttonRef.current?.focus()
+ },
+ [disabled, onChange, rest.name, rest.id],
+ )
+
+ // Handle keyboard navigation
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (disabled) return
+
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault()
+ if (!open) {
+ setOpen(true)
+ setHighlightedIndex(0)
+ } else {
+ setHighlightedIndex((prev) => {
+ const nextIndex = prev < items.length - 1 ? prev + 1 : 0
+ return nextIndex
+ })
+ }
+ break
+ case "ArrowUp":
+ event.preventDefault()
+ if (!open) {
+ setOpen(true)
+ setHighlightedIndex(items.length - 1)
+ } else {
+ setHighlightedIndex((prev) => {
+ const nextIndex = prev > 0 ? prev - 1 : items.length - 1
+ return nextIndex
+ })
+ }
+ break
+ case "Enter":
+ case " ":
+ event.preventDefault()
+ if (!open) {
+ setOpen(true)
+ setHighlightedIndex(0)
+ } else if (highlightedIndex >= 0) {
+ handleSelect(items[highlightedIndex])
+ }
+ break
+ case "Escape":
+ event.preventDefault()
+ setOpen(false)
+ setHighlightedIndex(-1)
+ buttonRef.current?.focus()
+ break
+ case "Tab":
+ setOpen(false)
+ setHighlightedIndex(-1)
+ break
+ }
+ },
+ [disabled, open, items, highlightedIndex, handleSelect],
+ )
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setOpen(false)
+ setHighlightedIndex(-1)
+ }
+ }
+
+ if (open) {
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }
+ }, [open])
+
+ // Scroll highlighted item into view
+ useEffect(() => {
+ if (open && highlightedIndex >= 0 && listRef.current) {
+ const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement
+ if (highlightedElement) {
+ highlightedElement.scrollIntoView({
+ block: "nearest",
+ behavior: "smooth",
+ })
+ }
+ }
+ }, [open, highlightedIndex])
+
+ const reactId = useId()
+ const buttonId = rest.id || `select-field-${reactId}`
+ const listId = `${buttonId}-list`
+
+ return (
+
+ {label && (
+
+ {label}
+ {required && * }
+
+ )}
+
+
+
!disabled && setOpen((prev) => !prev)}
+ onKeyDown={handleKeyDown}
+ disabled={disabled}
+ aria-haspopup="listbox"
+ aria-expanded={open}
+ aria-labelledby={label ? `${buttonId}-label` : undefined}
+ aria-describedby={error ? `${buttonId}-error` : description ? `${buttonId}-description` : undefined}
+ aria-controls={open ? listId : undefined}
+ className={cn(
+ "w-full flex justify-between items-center rounded-xl px-4 py-2.5 text-sm",
+ "transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2",
+ "focus:ring-[var(--org-primary,var(--primary,#145fff))]",
+ error
+ ? "border border-red-500"
+ : "border border-gray-300 dark:border-gray-600 focus:border-[var(--org-primary,var(--primary,#145fff))]",
+ disabled
+ ? "bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
+ : "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
+ className,
+ )}
+ >
+
+ {selectedItem ? selectedItem.label : placeholder}
+
+
+
+
+
+
+ {open && (
+
+
+ {items.length > 0 ? (
+ items.map((item, index) => (
+ handleSelect(item)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={cn(
+ "cursor-pointer px-4 py-2.5 text-sm transition-colors duration-150",
+ item.disabled
+ ? "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-500"
+ : "text-gray-700 dark:text-gray-200",
+ highlightedIndex === index &&
+ !item.disabled &&
+ "bg-[var(--org-primary-50,rgba(20,95,255,0.1))] dark:bg-[var(--org-primary-900,rgba(20,95,255,0.9))] dark:bg-opacity-20",
+ selectedItem &&
+ selectedItem.value === item.value &&
+ "bg-[var(--org-primary-100,rgba(20,95,255,0.2))] dark:bg-[var(--org-primary-800,rgba(20,95,255,0.8))] dark:bg-opacity-30 text-[var(--org-primary-700,var(--primary,#145fff))] dark:text-[var(--org-primary-200,rgba(20,95,255,0.4))] font-medium",
+ !item.disabled &&
+ "hover:bg-[var(--org-primary-50,rgba(20,95,255,0.1))] dark:hover:bg-[var(--org-primary-900,rgba(20,95,255,0.9))] dark:hover:bg-opacity-20",
+ )}
+ >
+ {item.label}
+
+ ))
+ ) : (
+ No options available
+ )}
+
+
+ )}
+
+
+ {error && (
+
+ )}
+
+ {!error && description && (
+
+ {description}
+
+ )}
+
+ )
+}
+
+export default ReusableSelectInput
diff --git a/src/vertex-template/components/shared/table/ReusableTable.tsx b/src/vertex-template/components/shared/table/ReusableTable.tsx
new file mode 100644
index 0000000000..caa9e8835c
--- /dev/null
+++ b/src/vertex-template/components/shared/table/ReusableTable.tsx
@@ -0,0 +1,1525 @@
+"use client";
+
+import React, {
+ Dispatch,
+ useState,
+ useMemo,
+ useRef,
+ useEffect,
+ useCallback,
+ ReactNode,
+} from "react";
+import type { SetStateAction } from "react";
+import Fuse from "fuse.js";
+import {
+ AqSearchSm,
+ AqChevronDown,
+ AqChevronUp,
+ AqChevronLeft,
+ AqChevronRight,
+ AqFilterLines,
+ AqXClose,
+} from "@airqo/icons-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import ReusableButton from "@/components/shared/button/ReusableButton";
+import { TableExportModal } from "./TableExportModal";
+import { AqDownload01 } from "@airqo/icons-react";
+
+// --- Type Definitions ---
+interface FilterOption {
+ label: string;
+ value: string | number | boolean;
+}
+
+interface FilterConfig {
+ key: string;
+ options: FilterOption[];
+ placeholder: string;
+ isMulti?: boolean;
+}
+
+export type TableColumn = {
+ key: K;
+ title?: string;
+ label?: React.ReactNode;
+ render: (value: T[keyof T], item: T) => React.ReactNode;
+ sortable?: boolean;
+ className?: string;
+};
+
+export type SortingState = Array<{ id: string; desc: boolean }>;
+
+type IconComponent = React.ComponentType<{ className?: string }>;
+
+type NoJSXIcon =
+ T extends React.ReactElement ? never : T;
+
+export interface TableAction {
+ label: string;
+ value: string;
+ handler: (selectedIds: (string | number)[]) => void;
+ icon?: NoJSXIcon;
+}
+
+export type TableItem = {
+ id: string | number;
+} & Record;
+
+export interface AdditionalExportField {
+ key: string;
+ title: string;
+}
+
+interface SortConfig {
+ key: string | null;
+ direction: "asc" | "desc";
+}
+
+// --- CustomFilter Component ---
+interface CustomFilterProps {
+ options: FilterOption[];
+ value: string | number | boolean | (string | number | boolean)[];
+ onChange: (
+ value: string | number | boolean | (string | number | boolean)[]
+ ) => void;
+ placeholder: string;
+ isMulti?: boolean;
+}
+
+const CustomFilter: React.FC = ({
+ options,
+ value,
+ onChange,
+ placeholder,
+ isMulti = false,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const filterRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ filterRef.current &&
+ !filterRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isOpen]);
+
+ const filteredOptions = useMemo(
+ () =>
+ options.filter((option) =>
+ option.label.toLowerCase().includes(searchTerm.toLowerCase())
+ ),
+ [options, searchTerm]
+ );
+
+ const handleSelect = useCallback(
+ (option: FilterOption) => {
+ if (isMulti) {
+ const currentValue = Array.isArray(value) ? value : [];
+ const newValue = currentValue.includes(option.value)
+ ? currentValue.filter((v) => v !== option.value)
+ : [...currentValue, option.value];
+ onChange(newValue);
+ } else {
+ onChange(option.value);
+ setIsOpen(false);
+ }
+ },
+ [isMulti, value, onChange]
+ );
+
+ const getDisplayValue = useCallback((): string => {
+ if (isMulti) {
+ const arrayValue = Array.isArray(value) ? value : [];
+ return arrayValue.length > 0
+ ? `${arrayValue.length} selected`
+ : placeholder;
+ }
+ const selected = options.find((opt) => opt.value === value);
+ return selected ? selected.label : placeholder;
+ }, [isMulti, value, options, placeholder]);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="w-full px-3 py-2 text-left bg-white dark:bg-[#1d1f20] border border-primary/30 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm text-gray-900 dark:text-gray-100"
+ type="button"
+ >
+ {getDisplayValue()}
+
+
+
+
+
+
+ {isOpen && (
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full px-2 py-1 text-sm border border-primary/30 dark:border-gray-700 rounded focus:outline-none focus:ring-1 focus:ring-primary bg-white dark:bg-[#232425] text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
+ />
+
+
+ {filteredOptions.map((option) => (
+
handleSelect(option)}
+ className={`px-3 py-2 cursor-pointer text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${(
+ isMulti
+ ? Array.isArray(value) && value.includes(option.value)
+ : value === option.value
+ )
+ ? "bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary"
+ : "text-gray-900 dark:text-gray-100"
+ }`}
+ >
+ {isMulti && (
+ { }} // Handled by parent div click
+ className="mr-2 text-primary focus:ring-primary"
+ />
+ )}
+ {option.label}
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+// --- TableHeader Component ---
+type FilterValue = string | number | boolean | (string | number | boolean)[];
+
+interface TableHeaderProps {
+ title: string;
+ searchable: boolean;
+ searchTerm: string;
+ onSearchChange: (term: string) => void;
+ onClearSearch: () => void;
+ filterable: boolean;
+ filters: FilterConfig[];
+ filterValues: Record;
+ onFilterChange: (key: keyof T, value: FilterValue) => void;
+ selectedCount: number;
+ exportable: boolean;
+ onExportClick: () => void;
+ hasData: boolean;
+ customHeaderContent?: ReactNode;
+}
+
+const TableHeader = ({
+ title,
+ searchable,
+ searchTerm,
+ onSearchChange,
+ onClearSearch,
+ filterable,
+ filters,
+ filterValues,
+ onFilterChange,
+ selectedCount,
+ exportable,
+ onExportClick,
+ hasData,
+ customHeaderContent,
+}: TableHeaderProps) => {
+ return (
+
+
+
+
+ {title}
+
+ {selectedCount > 0 && (
+
{selectedCount} item(s) selected
+ )}
+
+
+ {customHeaderContent}
+ {exportable && (
+
+ Export
+
+ )}
+ {searchable && (
+
+
+
onSearchChange(e.target.value)}
+ className="pl-10 pr-10 py-2 border border-primary/30 dark:border-primary/40 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm w-full sm:w-64 h-8 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
+ />
+ {searchTerm && (
+
+
+
+ )}
+
+ )}
+ {filterable && filters.length > 0 && (
+
+ {filters.map((filter) => (
+
+ onFilterChange(filter.key, value)}
+ placeholder={filter.placeholder}
+ isMulti={filter.isMulti}
+ />
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+// --- MultiSelectActionBar Component ---
+interface MultiSelectActionBarProps {
+ actions: TableAction[];
+ selectedIds: (string | number)[];
+ onClearSelection: () => void;
+}
+
+const MultiSelectActionBar: React.FC = ({
+ actions,
+ selectedIds,
+ onClearSelection,
+}) => {
+ return (
+
+
+ Clear
+
+
+
+
+
+ {actions.map((action) => (
+ action.handler(selectedIds)}
+ variant="outlined"
+ className="text-sm px-2 py-1"
+ title={action.label}
+ Icon={action.icon}
+ >
+ {action.label}
+
+ ))}
+
+
+ );
+};
+
+// --- Pagination Component ---
+interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ loading?: boolean;
+}
+
+const Pagination: React.FC = ({
+ currentPage,
+ totalPages,
+ onPageChange,
+ loading = false,
+}) => {
+ const generatePageNumbers = useCallback((): (number | string)[] => {
+ const pages: (number | string)[] = [];
+ const maxVisible = 5;
+ if (totalPages <= maxVisible) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ if (currentPage <= 3) {
+ pages.push(1, 2, 3, 4, "...", totalPages);
+ } else if (currentPage >= totalPages - 2) {
+ pages.push(
+ 1,
+ "...",
+ totalPages - 3,
+ totalPages - 2,
+ totalPages - 1,
+ totalPages
+ );
+ } else {
+ pages.push(
+ 1,
+ "...",
+ currentPage - 1,
+ currentPage,
+ currentPage + 1,
+ "...",
+ totalPages
+ );
+ }
+ }
+ return pages;
+ }, [totalPages, currentPage]);
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {totalPages > 1 && (
+
+
onPageChange(Math.max(currentPage - 1, 1))}
+ disabled={currentPage === 1}
+ className="px-2 py-1 text-xs border border-primary/30 dark:border-primary/40 rounded-md hover:bg-primary/10 dark:hover:bg-primary/20 hover:border-primary dark:hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-primary/30 dark:disabled:hover:border-primary/40 flex items-center space-x-1 transition-colors bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+ type="button"
+ >
+
+ Previous
+
+
+ {generatePageNumbers().map((page, index) => (
+ typeof page === "number" && onPageChange(page)}
+ disabled={typeof page !== "number"}
+ className={`px-2 py-1 text-xs border rounded-md transition-colors ${page === currentPage
+ ? "bg-primary text-white border-primary"
+ : typeof page === "number"
+ ? "border-primary/30 dark:border-primary/40 hover:bg-primary/10 dark:hover:bg-primary/20 hover:border-primary dark:hover:border-primary text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-900"
+ : "border-transparent cursor-default bg-transparent dark:bg-transparent text-gray-400 dark:text-gray-500"
+ }`}
+ type="button"
+ >
+ {page}
+
+ ))}
+
+
+ onPageChange(Math.min(currentPage + 1, totalPages))
+ }
+ disabled={currentPage === totalPages}
+ className="px-2 py-1 text-xs border border-primary/30 dark:border-primary/40 rounded-md hover:bg-primary/10 dark:hover:bg-primary/20 hover:border-primary dark:hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-primary/30 dark:disabled:hover:border-primary/40 flex items-center space-x-1 transition-colors bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+ type="button"
+ >
+ Next
+
+
+
+ )}
+
+
+ );
+};
+
+// --- TableSkeleton Component ---
+interface TableSkeletonProps {
+ columns: { key: PropertyKey }[];
+ pageSize: number;
+}
+
+const TableSkeleton: React.FC = ({
+ columns,
+ pageSize,
+}) => {
+ return (
+
+
+
+ {columns.map((column) => (
+
+ {column.key === "checkbox" ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {Array.from({ length: pageSize }).map((_, index) => (
+
+ {columns.map((column) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+};
+
+// --- Main ReusableTable Component ---
+interface ReusableTableProps {
+ title?: string;
+ data?: T[];
+ columns?: TableColumn[];
+ searchable?: boolean;
+ filterable?: boolean;
+ filters?: FilterConfig[];
+ pageSize?: number;
+ showPagination?: boolean;
+ sortable?: boolean;
+ pageSizeOptions?: number[];
+ searchableColumns?: string[] | null;
+ loading?: boolean;
+ loadingComponent?: ReactNode;
+ multiSelect?: boolean;
+ actions?: TableAction[];
+ onSelectedIdsChange?: (selectedIds: (string | number)[]) => void;
+ onSelectedItemsChange?: (selectedItems: T[]) => void;
+ onRowClick?: (item: T) => void;
+ emptyState?: ReactNode;
+ className?: string;
+ tableId?: string | boolean;
+ isRowSelectable?: (item: T) => boolean;
+
+ // Server-side operation props
+ serverSidePagination?: boolean;
+ pageCount?: number;
+ pagination?: { pageIndex: number; pageSize: number };
+ onPaginationChange?: Dispatch>;
+ sorting?: SortingState;
+ onSortingChange?: Dispatch>;
+ stickyHeader?: boolean;
+ onSearchChange?: (searchTerm: string) => void;
+ searchTerm?: string;
+ exportable?: boolean;
+ onExport?: (format: 'csv', selectedColumns: string[]) => Promise;
+ additionalExportFields?: AdditionalExportField[];
+ customHeaderContent?: ReactNode;
+}
+
+// Normalize any value to a searchable string
+const normalizeToString = (value: unknown): string => {
+ if (value == null) return "";
+ if (Array.isArray(value)) {
+ return value
+ .map((v) => normalizeToString(v))
+ .filter(Boolean)
+ .join(" ");
+ }
+ const t = typeof value;
+ if (t === "string") return value as string;
+ if (t === "number" || t === "boolean") return String(value);
+ if (t === "object") {
+ const obj = value as Record;
+ if (typeof obj.name === "string") return obj.name as string;
+ if (typeof obj.long_name === "string") return obj.long_name as string;
+ if (typeof obj.label === "string") return obj.label as string;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return "";
+ }
+ }
+ return "";
+};
+
+const ReusableTable = ({
+ title = "Table",
+ data = [],
+ columns = [],
+ searchable = true,
+ filterable = true,
+ filters = [],
+ pageSize = 10,
+ showPagination = true,
+ sortable = true,
+ pageSizeOptions = [5, 10, 20, 50, 100],
+ searchableColumns = null,
+ loading = false,
+ loadingComponent = null,
+ multiSelect = false,
+ actions = [],
+ onSelectedIdsChange,
+ onSelectedItemsChange,
+ onRowClick,
+ emptyState = "No data available",
+ className = "",
+ tableId: tableIdProp,
+ isRowSelectable = () => true,
+ // Server-side props
+ serverSidePagination = false,
+ pageCount = 0,
+ pagination: paginationProp,
+ onPaginationChange,
+ sorting: sortingProp,
+ onSortingChange,
+ onSearchChange,
+ searchTerm: searchTermProp,
+ stickyHeader = false,
+ exportable = true,
+ onExport,
+ additionalExportFields = [],
+ customHeaderContent,
+}: ReusableTableProps) => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const stickyHeaderRef = useRef(null);
+
+ const [selectedItems, setSelectedItems] = useState([]);
+ const [isExportModalOpen, setIsExportModalOpen] = useState(false);
+
+ const headerCheckboxRef = useRef(null);
+
+ const slugify = (str: string) =>
+ str
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-");
+
+ const tableId = useMemo(() => {
+ if (tableIdProp === false) return undefined;
+
+ if (typeof tableIdProp === "string") return tableIdProp;
+
+ const pathId = pathname.replace(/^\//, "").replace(/\//g, "-");
+ return `${pathId}-${slugify(title)}`;
+ }, [tableIdProp, title, pathname]);
+
+ const initialFilters = useMemo(
+ () =>
+ filters.reduce>((acc, filter) => {
+ acc[filter.key] = filter.isMulti ? [] : "";
+ return acc;
+ }, {}),
+ [filters]
+ );
+
+ const [localSearchTerm, setLocalSearchTerm] = useState("");
+ const [localCurrentPage, setLocalCurrentPage] = useState(1);
+ const [localSortConfig, setLocalSortConfig] = useState({ key: null, direction: "asc" });
+ const [localFilterValues, setLocalFilterValues] = useState(initialFilters);
+
+ const urlState = useMemo(() => {
+ if (!tableId) return null;
+ try {
+ const s = searchParams.get(`${tableId}_search`) || "";
+ const rawP = Number.parseInt(searchParams.get(`${tableId}_page`) || "1", 10);
+ const rawPs = Number.parseInt(
+ searchParams.get(`${tableId}_pageSize`) || String(pageSize),
+ 10,
+ );
+ const p = Number.isFinite(rawP) && rawP >= 1 ? rawP : 1;
+ const psUnsafe = Number.isFinite(rawPs) && rawPs > 0 ? rawPs : pageSize;
+ const ps = Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0
+ ? (pageSizeOptions.includes(psUnsafe) ? psUnsafe : pageSize)
+ : psUnsafe;
+ const sortRaw = searchParams.get(`${tableId}_sort`);
+ const sort = sortRaw ? JSON.parse(sortRaw) : { key: null, direction: "asc" };
+ const filtersRaw = searchParams.get(`${tableId}_filters`);
+ const filters = filtersRaw ? JSON.parse(filtersRaw) : initialFilters;
+ return { searchTerm: s, currentPage: p, currentPageSize: ps, sortConfig: sort, filterValues: filters };
+ } catch {
+ return { searchTerm: "", currentPage: 1, currentPageSize: pageSize, sortConfig: { key: null, direction: "asc" }, filterValues: initialFilters };
+ }
+ }, [tableId, searchParams, pageSize, pageSizeOptions, initialFilters]);
+
+ const searchTerm = serverSidePagination
+ ? searchTermProp ?? ""
+ : tableId
+ ? urlState!.searchTerm
+ : localSearchTerm;
+ const currentPage = tableId ? urlState!.currentPage : localCurrentPage;
+ const currentPageSize = tableId ? urlState!.currentPageSize : pageSize;
+ const sortConfig = tableId ? urlState!.sortConfig : localSortConfig;
+ const filterValues = tableId ? urlState!.filterValues : localFilterValues;
+
+ const updateUrlState = useCallback((updates: Partial<{ search: string; page: number; pageSize: number; sort: SortConfig; filters: Record }>) => {
+ if (!tableId) return;
+ const params = new URLSearchParams(searchParams.toString());
+ Object.entries(updates).forEach(([key, value]) => {
+ const fullKey = `${tableId}_${key}`;
+ if (value === undefined) return;
+
+ const defaultValue = { search: "", page: 1, pageSize, sort: { key: null, direction: "asc" }, filters: initialFilters }[key as keyof typeof updates];
+
+ if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
+ params.delete(fullKey);
+ } else {
+ params.set(fullKey, typeof value === 'object' ? JSON.stringify(value) : String(value));
+ }
+ });
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+ }, [tableId, searchParams, router, pathname, pageSize, initialFilters]);
+
+ const [searchInput, setSearchInput] = useState(searchTerm);
+ const lastLocalUpdateRef = useRef(searchTerm);
+
+ // Sync search input only when searchTerm prop changes externally (e.g., from router back or parent)
+ // and it doesn't match our last intentional local update.
+ useEffect(() => {
+ if (searchTerm !== lastLocalUpdateRef.current) {
+ setSearchInput(searchTerm);
+ lastLocalUpdateRef.current = searchTerm;
+ }
+ }, [searchTerm]);
+
+ // Helper to validate search input
+ const validateSearchInput = (input: string) => {
+ // Block URLs and common invalid patterns that might trigger WAFs or router loops
+ if (/https?:\/\/|www\./i.test(input)) return false;
+ return true;
+ };
+
+ useEffect(() => {
+ if (searchInput !== searchTerm) {
+ // Validate input before processing
+ if (!validateSearchInput(searchInput)) {
+ return;
+ }
+
+ if (serverSidePagination) {
+ const t = setTimeout(() => {
+ lastLocalUpdateRef.current = searchInput;
+ onSearchChange?.(searchInput);
+ }, 400);
+ return () => clearTimeout(t);
+ }
+
+ if (tableId) {
+ const t = setTimeout(() => {
+ lastLocalUpdateRef.current = searchInput;
+ updateUrlState({ search: searchInput, page: 1 });
+ }, 400);
+
+ return () => clearTimeout(t);
+ } else {
+ lastLocalUpdateRef.current = searchInput;
+ setLocalSearchTerm(searchInput);
+ if (!serverSidePagination) setLocalCurrentPage(1);
+ }
+ }
+ }, [
+ searchInput,
+ searchTerm,
+ tableId,
+ updateUrlState,
+ setLocalSearchTerm,
+ setLocalCurrentPage,
+ serverSidePagination,
+ onSearchChange,
+ ]);
+
+ const handleClearSearch = useCallback(() => {
+ setSearchInput("");
+ }, []);
+
+ const resolvePath = (obj: unknown, path: string): unknown => {
+ const parts = path.split(".");
+ const walk = (current: unknown, idx: number): unknown => {
+ if (current == null) return undefined;
+ if (idx >= parts.length) return current;
+ if (Array.isArray(current)) {
+ return current
+ .map((el) => walk(el, idx))
+ .filter((v) => v != null && v !== "") as unknown[];
+ }
+ if (typeof current === "object") {
+ const next = (current as Record)[parts[idx]];
+ return walk(next, idx + 1);
+ }
+ return undefined;
+ };
+ return walk(obj, 0);
+ };
+
+ const handleExport = async (selectedColumns: string[], scope: 'current' | 'all') => {
+ if (onExport && scope === 'all' && serverSidePagination) {
+ await onExport('csv', selectedColumns);
+ return;
+ }
+
+ // Client-side export logic
+ const dataToExport = scope === 'current' ? finalPaginatedData : (serverSidePagination ? data : filteredData);
+
+ // Dynamically import papaparse
+ const Papa = (await import('papaparse')).default;
+
+ const csvData = dataToExport.map(item => {
+ const row: Record = {};
+ selectedColumns.forEach(colKey => {
+ const colDef = columns.find(c => c.key === colKey);
+ const additionalField = additionalExportFields.find(f => f.key === colKey);
+
+ if (colDef) {
+ // Use the column's render function to get the exact displayed value
+ const rawValue = item[colKey as keyof T];
+ let displayValue = '';
+
+ try {
+ const rendered = colDef.render(rawValue, item);
+
+ // Extract text from rendered content
+ if (typeof rendered === 'string' || typeof rendered === 'number') {
+ displayValue = String(rendered);
+ } else if (React.isValidElement(rendered)) {
+ // Extract text from React elements
+ const extractText = (element: unknown): string => {
+ if (typeof element === 'string' || typeof element === 'number') {
+ return String(element);
+ }
+ if (React.isValidElement(element)) {
+ const props = element.props as { children?: unknown };
+ if (props.children) {
+ if (Array.isArray(props.children)) {
+ return props.children.map(extractText).join(' ');
+ }
+ return extractText(props.children);
+ }
+ }
+ return '';
+ };
+ displayValue = extractText(rendered);
+ } else {
+ // Fallback to raw value
+ displayValue = normalizeToString(rawValue);
+ }
+ } catch {
+ // If render fails, use raw value
+ displayValue = normalizeToString(rawValue);
+ }
+
+ // Use the column title (or label) as it appears in the table header
+ const columnHeader = colDef.title || (typeof colDef.label === 'string' ? colDef.label : String(colKey));
+ row[columnHeader] = displayValue;
+ } else if (additionalField) {
+ // If it's an additional field, use raw value
+ const rawValue = item[colKey as keyof T];
+ row[additionalField.title] = normalizeToString(rawValue);
+ }
+ });
+ return row;
+ });
+
+ const csv = Papa.unparse(csvData);
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', `${title.toLowerCase().replace(/\s+/g, '_')}_export.csv`);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ const filteredData = useMemo(() => {
+ if (serverSidePagination) {
+ return data;
+ }
+
+ let result = [...data];
+
+ Object.entries(filterValues).forEach(([key, value]) => {
+ if (
+ value !== undefined &&
+ value !== null &&
+ value !== "" &&
+ !(Array.isArray(value) && value.length === 0)
+ ) {
+ result = result.filter((item) => {
+ if (Array.isArray(value)) {
+ const itemValue = item[key];
+ if (
+ typeof itemValue === "string" ||
+ typeof itemValue === "number" ||
+ typeof itemValue === "boolean"
+ ) {
+ return value.includes(itemValue);
+ }
+ return false;
+ }
+
+ if (
+ typeof value === "boolean" ||
+ value === "true" ||
+ value === "false"
+ ) {
+ return String(item[key]) === String(value);
+ }
+ return item[key] === value;
+ });
+ }
+ });
+
+ let fuseKeys: string[];
+ if (Array.isArray(searchableColumns) && searchableColumns.length > 0) {
+ fuseKeys = searchableColumns;
+ } else {
+ const allKeys = columns.map((col) => col.key);
+ const keysWithData = allKeys.filter((key) =>
+ result.some(
+ (item) =>
+ item[key] !== null &&
+ item[key] !== undefined &&
+ String(item[key]).trim() !== ""
+ )
+ );
+ fuseKeys = (keysWithData.length > 0 ? keysWithData : allKeys) as Extract<
+ keyof T,
+ string
+ >[];
+ }
+
+ if (searchTerm && searchTerm.trim() && fuseKeys.length > 0) {
+ const getSearchableString = (item: T, key: string): string => {
+ const col = columns.find((c) => c.key === key);
+ if (col && typeof col.render === "function") {
+ try {
+ const ckey = col.key as string;
+ const cval = item[ckey as keyof T] as T[keyof T];
+ const rendered = col.render(cval, item);
+
+ if (typeof rendered === "string") return rendered;
+ if (typeof rendered === "number") return String(rendered);
+ if (React.isValidElement(rendered)) {
+ const extractText = (element: unknown): string => {
+ if (
+ typeof element === "string" ||
+ typeof element === "number"
+ ) {
+ return String(element);
+ }
+ if (React.isValidElement(element)) {
+ const props = element.props as { children?: unknown };
+ if (props.children) {
+ if (Array.isArray(props.children)) {
+ return props.children.map(extractText).join(" ");
+ }
+ return extractText(props.children);
+ }
+ }
+ return "";
+ };
+ const extractedText = extractText(rendered);
+ if (extractedText) return extractedText;
+ }
+ } catch {
+ // fall through to raw/nested resolution
+ }
+ }
+
+ if (key in item) {
+ return normalizeToString(item[key as keyof T]);
+ }
+ const nestedValue = resolvePath(item, key);
+ if (nestedValue !== undefined) {
+ return normalizeToString(nestedValue);
+ }
+
+ return "";
+ };
+
+ const fuseData = result.map((item) => {
+ const obj: Record = {};
+ fuseKeys.forEach((key) => {
+ obj[key] = getSearchableString(item, key);
+ });
+ return obj;
+ });
+
+ const fuseOptions = {
+ keys: fuseKeys,
+ threshold: searchTerm.trim().length < 3 ? 0.4 : 0.3,
+ ignoreLocation: true,
+ minMatchCharLength: 1,
+ isCaseSensitive: false,
+ includeScore: true,
+ includeMatches: true,
+ shouldSort: true,
+ findAllMatches: true,
+ ignoreFieldNorm: true,
+ };
+ const fuse = new Fuse(fuseData, fuseOptions);
+ const fuseResults = fuse.search(searchTerm.trim());
+ result = fuseResults.map((searchResult) => result[searchResult.refIndex]);
+ }
+
+ return result;
+ }, [data, filterValues, searchableColumns, searchTerm, columns, serverSidePagination]);
+
+ const clientSortedData = useMemo(() => {
+ if (serverSidePagination || !sortConfig.key) return filteredData;
+ return [...filteredData].sort((a, b) => {
+ const aValue = a[sortConfig.key!];
+ const bValue = b[sortConfig.key!];
+ if (aValue === null || aValue === undefined) return 1;
+ if (bValue === null || bValue === undefined) return -1;
+ const aStr = String(aValue).toLowerCase();
+ const bStr = String(bValue).toLowerCase();
+ if (aStr < bStr) {
+ return sortConfig.direction === "asc" ? -1 : 1;
+ }
+ if (aStr > bStr) {
+ return sortConfig.direction === "asc" ? 1 : -1;
+ }
+ return 0;
+ });
+ }, [filteredData, sortConfig, serverSidePagination]);
+
+ const clientPaginatedData = useMemo(() => {
+ if (serverSidePagination) return clientSortedData;
+ const start = (currentPage - 1) * currentPageSize;
+ const end = start + currentPageSize;
+ return clientSortedData.slice(start, end);
+ }, [clientSortedData, currentPage, currentPageSize, serverSidePagination]);
+
+ const clientTotalPages = Math.ceil(clientSortedData.length / Math.max(1, currentPageSize || 1));
+
+ const finalPaginatedData = serverSidePagination ? data : clientPaginatedData;
+ const finalTotalPages = serverSidePagination ? pageCount : clientTotalPages;
+ const finalCurrentPage = serverSidePagination ? (paginationProp?.pageIndex ?? 0) + 1 : currentPage;
+ const finalCurrentPageSize = serverSidePagination ? paginationProp?.pageSize ?? pageSize : currentPageSize;
+
+ const handleClientPageChange = useCallback((page: number) => {
+ const clamped = Math.max(1, Math.min(page, Math.max(1, clientTotalPages)));
+ if (tableId) {
+ updateUrlState({ page: clamped });
+ } else {
+ setLocalCurrentPage(clamped);
+ }
+ }, [clientTotalPages, tableId, updateUrlState]);
+
+ const handleServerPageChange = useCallback((page: number) => {
+ onPaginationChange?.(old => ({ ...old, pageIndex: page - 1 }));
+ }, [onPaginationChange]);
+
+ const handlePageChange = serverSidePagination ? handleServerPageChange : handleClientPageChange;
+
+ const totalPages = serverSidePagination ? pageCount : clientTotalPages;
+
+ useEffect(() => {
+ // Don't run validation while loading, as `totalPages` might be stale from `keepPreviousData`.
+ if (loading) return;
+
+ if (finalCurrentPage > totalPages || finalCurrentPage < 1 || !Number.isFinite(finalCurrentPage)) {
+ handlePageChange(totalPages > 0 ? Math.min(Math.max(1, finalCurrentPage), totalPages) : 1);
+ }
+ }, [finalCurrentPage, totalPages, handlePageChange, loading]);
+
+ const handleSort = useCallback(
+ (key: string) => {
+ if (!sortable) return;
+
+ if (serverSidePagination) {
+ onSortingChange?.(old => {
+ const currentSort = old?.[0];
+ if (currentSort?.id === key) {
+ return [{ id: key, desc: !currentSort.desc }];
+ }
+ return [{ id: key, desc: false }];
+ });
+ return;
+ }
+ const newSortConfig = {
+ key,
+ direction: sortConfig.key === key && sortConfig.direction === "asc" ? "desc" : "asc",
+ } as SortConfig;
+ if (tableId) {
+ updateUrlState({ sort: newSortConfig });
+ } else {
+ setLocalSortConfig(newSortConfig);
+ }
+ },
+ [sortable, tableId, updateUrlState, sortConfig, serverSidePagination, onSortingChange]
+ );
+
+ const handleFilterChange = useCallback((key: string, value: FilterValue) => {
+ const newFilters = { ...filterValues, [key]: value };
+ if (tableId) {
+ updateUrlState({ filters: newFilters, page: 1 });
+ } else {
+ setLocalFilterValues(newFilters);
+ setLocalCurrentPage(1);
+ }
+ }, [tableId, updateUrlState, filterValues]);
+
+ const getSortIcon = useCallback(
+ (key: string): ReactNode => {
+ if (serverSidePagination) {
+ const sort = sortingProp?.[0];
+ if (sort?.id !== key) {
+ return ;
+ }
+ return !sort.desc ? (
+
+ ) : (
+
+ );
+ } else {
+ if (sortConfig.key !== key)
+ return ;
+ return sortConfig.direction === "asc" ? (
+
+ ) : (
+
+ );
+ }
+ },
+ [sortConfig, serverSidePagination, sortingProp]
+ );
+
+ const renderCell = useCallback(
+ (item: T, column: TableColumn): ReactNode => {
+ if (column.render) {
+ return column.render(item[column.key], item);
+ }
+ const value = item[column.key];
+ return value === null || value === undefined ? "" : String(value);
+ },
+ []
+ );
+
+ // Multi-Select Logic
+ const handleSelectAll = useCallback(
+ (isChecked: boolean) => {
+ if (isChecked) {
+ setSelectedItems((prevSelected) => {
+ const newItems = finalPaginatedData.filter(
+ (item) => isRowSelectable(item) && !prevSelected.some(prev => prev.id === item.id)
+ );
+ const updatedSelected = [...prevSelected, ...newItems];
+ onSelectedItemsChange?.(updatedSelected);
+ onSelectedIdsChange?.(updatedSelected.map(item => item.id));
+ return updatedSelected;
+ });
+ } else {
+ const currentPageIds = new Set(finalPaginatedData.map((item) => item.id));
+ setSelectedItems((prevSelected) => {
+ const updatedSelected = prevSelected.filter(
+ (item) => !currentPageIds.has(item.id)
+ );
+ onSelectedItemsChange?.(updatedSelected);
+ onSelectedIdsChange?.(updatedSelected.map(item => item.id));
+ return updatedSelected;
+ });
+ }
+ },
+ [finalPaginatedData, onSelectedItemsChange, onSelectedIdsChange, isRowSelectable]
+ );
+
+ const handleSelectItem = useCallback(
+ (item: T, isChecked: boolean) => {
+ if (isChecked) {
+ setSelectedItems((prevSelected) => {
+ const updatedSelected = [...prevSelected, item];
+ onSelectedItemsChange?.(updatedSelected);
+ onSelectedIdsChange?.(updatedSelected.map(i => i.id));
+ return updatedSelected;
+ });
+ } else {
+ setSelectedItems((prevSelected) => {
+ const updatedSelected = prevSelected.filter((i) => i.id !== item.id);
+ onSelectedItemsChange?.(updatedSelected);
+ onSelectedIdsChange?.(updatedSelected.map(i => i.id));
+ return updatedSelected;
+ });
+ }
+ },
+ [onSelectedIdsChange, onSelectedItemsChange]
+ );
+
+ const isAllSelectedOnPage = useMemo(
+ () => {
+ const selectableItems = finalPaginatedData.filter(item => isRowSelectable(item));
+ return selectableItems.length > 0 &&
+ selectableItems.every((item) => selectedItems.some(sel => sel.id === item.id));
+ },
+ [finalPaginatedData, selectedItems, isRowSelectable]
+ );
+
+ const isIndeterminate = useMemo(
+ () =>
+ selectedItems.length > 0 &&
+ !isAllSelectedOnPage,
+ [selectedItems, isAllSelectedOnPage]
+ );
+
+ useEffect(() => {
+ if (headerCheckboxRef.current) {
+ headerCheckboxRef.current.indeterminate = isIndeterminate;
+ }
+ }, [isIndeterminate]);
+
+ const isAnySelected = selectedItems.length > 0;
+
+ const handleClearSelection = useCallback(() => {
+ setSelectedItems([]);
+ onSelectedItemsChange?.([]);
+ onSelectedIdsChange?.([]);
+ }, [onSelectedItemsChange, onSelectedIdsChange]);
+
+
+ const displayColumns = useMemo((): TableColumn[] => {
+ const cols = [...columns];
+ if (multiSelect) {
+ cols.unshift({
+ key: "checkbox",
+ label: (
+
+ e.stopPropagation()}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ aria-label="Select all visible rows"
+ />
+
+ ),
+ render: (_value: T[keyof T], item: T) => {
+ const selectable = isRowSelectable(item);
+ return (
+ sel.id === item.id)}
+ disabled={!selectable}
+ onClick={(e) => e.stopPropagation()}
+ onChange={(e) => handleSelectItem(item, e.target.checked)}
+ title={!selectable ? 'This item cannot be selected' : ''}
+ />
+ );
+ },
+ sortable: false,
+ });
+ }
+ return cols;
+ }, [
+ columns,
+ multiSelect,
+ isAllSelectedOnPage,
+ selectedItems,
+ handleSelectAll,
+ handleSelectItem,
+ isRowSelectable,
+ ]);
+
+ // Add refs for scroll synchronization
+ const theadScrollRef = useRef(null);
+ const tbodyScrollRef = useRef(null);
+
+ // Add scroll sync handler
+ const handleScroll = useCallback((source: 'thead' | 'tbody') => {
+ return (e: React.UIEvent) => {
+ const scrollLeft = e.currentTarget.scrollLeft;
+ if (source === 'tbody' && theadScrollRef.current) {
+ theadScrollRef.current.scrollLeft = scrollLeft;
+ } else if (source === 'thead' && tbodyScrollRef.current) {
+ tbodyScrollRef.current.scrollLeft = scrollLeft;
+ }
+ };
+ }, []);
+
+ return (
+
+ {/* 1. Header Section */}
+
+
setIsExportModalOpen(true)}
+ hasData={serverSidePagination ? data.length > 0 : filteredData.length > 0}
+ customHeaderContent={customHeaderContent}
+ />
+
+ {multiSelect && isAnySelected && (
+ item.id)}
+ onClearSelection={handleClearSelection}
+ />
+ )}
+
+
+ {/* 2. Scrollable table section with fixed header */}
+
+ {/* Table Header (thead) - Sticky within the scrollable area */}
+ {!loading && (
+
+
+
+
+
+ {displayColumns.map((column) => (
+
+ sortable &&
+ column.sortable !== false &&
+ column.key !== "checkbox" &&
+ handleSort(String(column.key))
+ }
+ >
+ {column.key === "checkbox" ? (
+ column.label
+ ) : (
+
+ {column.label}
+ {sortable &&
+ column.sortable !== false &&
+ getSortIcon(String(column.key))}
+
+ )}
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Scrollable Table Body - max-height creates internal scroll */}
+
+ {loading ? (
+ loadingComponent ? (
+ loadingComponent
+ ) : (
+
+ )
+ ) : (
+
+
+ {finalPaginatedData.length > 0 ? (
+ finalPaginatedData.map((item, index) => (
+ {
+ const target = e.target as HTMLElement | null;
+ if (
+ (target &&
+ target.closest(
+ 'input, button, a, [role="button"], [data-no-rowclick]'
+ )) ||
+ (target instanceof HTMLInputElement &&
+ target.type === "checkbox")
+ ) {
+ return;
+ }
+ if (onRowClick) {
+ onRowClick(item);
+ }
+ }}
+ className={`${selectedItems.some(sel => sel.id === item.id)
+ ? "bg-primary/10 dark:bg-primary/20"
+ : "hover:bg-primary/5 dark:hover:bg-primary/20"
+ } ${onRowClick ? "cursor-pointer" : ""}`}
+ >
+ {displayColumns.map((column) => (
+
+ {renderCell(item, column)}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {searchTerm ||
+ Object.values(filterValues).some(
+ (v) => v && (Array.isArray(v) ? v.length > 0 : v !== "")
+ )
+ ? "No matching results found"
+ : emptyState}
+
+
+ )}
+
+
+ )}
+
+
+
+ {/* Pagination */}
+ {showPagination && (finalPaginatedData.length > 0 || loading) && (
+
+ )}
+
+ {/* Add CSS to hide scrollbar on thead */}
+
+
+ {/* Export Modal */}
+ {exportable && (
+
setIsExportModalOpen(false)}
+ onExport={handleExport}
+ columns={columns
+ .map(col => ({
+ key: String(col.key),
+ title:
+ col.title ||
+ (typeof col.label === 'string' ? col.label : String(col.key)),
+ }))
+ .filter(c => c.key !== 'checkbox' && c.key !== 'actions')}
+ additionalColumns={additionalExportFields.filter(
+ c => c.key !== 'checkbox' && c.key !== 'actions'
+ )}
+ totalRows={serverSidePagination ? (pageCount * pageSize) : filteredData.length}
+ currentPageRows={finalPaginatedData.length}
+ hasServerSidePagination={serverSidePagination}
+ />
+ )}
+
+ );
+};
+
+ReusableTable.defaultProps = {
+ title: "Table",
+ data: [],
+ columns: [],
+ searchable: true,
+ filterable: true,
+ filters: [],
+ pageSize: 10,
+ showPagination: true,
+ sortable: true,
+ pageSizeOptions: [5, 10, 20, 50, 100],
+ loading: false,
+ multiSelect: false,
+ actions: [],
+ tableId: undefined,
+ stickyHeader: false,
+};
+
+export default ReusableTable;
diff --git a/src/vertex-template/components/shared/table/TableExportModal.tsx b/src/vertex-template/components/shared/table/TableExportModal.tsx
new file mode 100644
index 0000000000..9164e0b824
--- /dev/null
+++ b/src/vertex-template/components/shared/table/TableExportModal.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import React, { useMemo, useState, useEffect } from 'react';
+import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
+import { InfoBanner } from '@/components/ui/banner';
+
+interface TableExportModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onExport: (selectedColumns: string[], scope: 'current' | 'all') => void;
+ columns: { key: string; title: string }[];
+ additionalColumns?: { key: string; title: string }[];
+ totalRows: number;
+ currentPageRows: number;
+ hasServerSidePagination: boolean;
+}
+
+export const TableExportModal: React.FC = ({
+ isOpen,
+ onClose,
+ onExport,
+ columns,
+ additionalColumns,
+ totalRows,
+ currentPageRows,
+}) => {
+ const [selectedColumns, setSelectedColumns] = useState([]);
+ const hasMoreThanOnePage = totalRows > currentPageRows;
+
+ const allColumns = useMemo(() => {
+ const merged = [...(columns || []), ...(additionalColumns || [])];
+ const seen = new Set();
+ return merged.filter((c) => {
+ if (!c?.key) return false;
+ if (seen.has(c.key)) return false;
+ seen.add(c.key);
+ return true;
+ });
+ }, [columns, additionalColumns]);
+
+ const additionalOnlyColumns = useMemo(() => {
+ if (!additionalColumns || additionalColumns.length === 0) return [];
+ const baseKeys = new Set((columns || []).map((c) => c.key));
+ return additionalColumns.filter((c) => c?.key && !baseKeys.has(c.key));
+ }, [additionalColumns, columns]);
+
+ // Initialize selected columns with all columns when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ setSelectedColumns(allColumns.map(col => col.key));
+ }
+ }, [isOpen, allColumns]);
+
+ const handleColumnToggle = (key: string) => {
+ setSelectedColumns(prev =>
+ prev.includes(key)
+ ? prev.filter(k => k !== key)
+ : [...prev, key]
+ );
+ };
+
+ const handleSelectAll = () => {
+ if (selectedColumns.length === allColumns.length) {
+ setSelectedColumns([]);
+ } else {
+ setSelectedColumns(allColumns.map(col => col.key));
+ }
+ };
+
+ const handleExport = () => {
+ onExport(selectedColumns, 'current');
+ onClose();
+ };
+
+ const exportCount = currentPageRows;
+
+ return (
+
+
+
+
+
+
+
+ Select Columns
+
+
+ {selectedColumns.length === allColumns.length ? 'Deselect All' : 'Select All'}
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/vertex-template/components/shared/toast/ReusableToast.tsx b/src/vertex-template/components/shared/toast/ReusableToast.tsx
new file mode 100644
index 0000000000..e03e179152
--- /dev/null
+++ b/src/vertex-template/components/shared/toast/ReusableToast.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { Toaster as Sonner, toast as sonnerToast } from 'sonner';
+import React from 'react';
+
+// ============================================================================
+// Types & Interfaces
+// ============================================================================
+
+type ToasterProps = React.ComponentProps;
+
+type ToastVariant = 'success' | 'error' | 'warning' | 'info';
+
+interface ToastOptions {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ variant?: ToastVariant;
+ duration?: number;
+}
+
+interface ToastBodyProps extends ToastOptions {
+ onClose: () => void;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const VARIANT_CONFIG: Record<
+ ToastVariant,
+ {
+ stripe: string;
+ titleColor: string;
+ icon?: React.ReactNode;
+ }
+> = {
+ success: {
+ stripe: 'bg-emerald-500',
+ titleColor: 'text-foreground',
+ },
+ error: {
+ stripe: 'bg-rose-500',
+ titleColor: 'text-foreground',
+ },
+ warning: {
+ stripe: 'bg-amber-500',
+ titleColor: 'text-foreground',
+ },
+ info: {
+ stripe: 'bg-sky-500',
+ titleColor: 'text-foreground',
+ },
+};
+
+const DEFAULT_DURATION = 5000;
+const DEFAULT_VARIANT: ToastVariant = 'info';
+
+// ============================================================================
+// ToastBody Component
+// ============================================================================
+
+const ToastBody: React.FC = ({
+ title,
+ description,
+ variant = DEFAULT_VARIANT,
+ onClose,
+}) => {
+ const config = VARIANT_CONFIG[variant];
+
+ return (
+
+ {/* Left colored stripe - with spacing from edge */}
+
+
+ {/* Content area */}
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {/* Close button */}
+
+
+
+
+
+
+
+ );
+};
+
+ToastBody.displayName = 'ToastBody';
+
+// ============================================================================
+// Toaster Component
+// ============================================================================
+
+const Toaster: React.FC = ({ ...props }) => {
+ return (
+
+ );
+};
+
+Toaster.displayName = 'Toaster';
+
+// ============================================================================
+// Toast Helper Functions
+// ============================================================================
+
+const showToast = (options: ToastOptions) => {
+ const {
+ title,
+ description,
+ variant = DEFAULT_VARIANT,
+ duration = DEFAULT_DURATION,
+ } = options;
+
+ const toastId = sonnerToast.custom(
+ id => (
+
+ sonnerToast.dismiss(id)}
+ />
+
+ ),
+ { duration }
+ );
+
+ return toastId;
+};
+
+// Convenience methods for different toast types
+const toast = {
+ success: (
+ title: React.ReactNode,
+ description?: React.ReactNode,
+ duration?: number
+ ) =>
+ showToast({
+ title,
+ description,
+ variant: 'success',
+ duration,
+ }),
+
+ error: (
+ title: React.ReactNode,
+ description?: React.ReactNode,
+ duration?: number
+ ) =>
+ showToast({
+ title,
+ description,
+ variant: 'error',
+ duration,
+ }),
+
+ warning: (
+ title: React.ReactNode,
+ description?: React.ReactNode,
+ duration?: number
+ ) =>
+ showToast({
+ title,
+ description,
+ variant: 'warning',
+ duration,
+ }),
+
+ info: (
+ title: React.ReactNode,
+ description?: React.ReactNode,
+ duration?: number
+ ) =>
+ showToast({
+ title,
+ description,
+ variant: 'info',
+ duration,
+ }),
+
+ custom: showToast,
+
+ dismiss: (toastId?: string | number) => sonnerToast.dismiss(toastId),
+};
+
+// ============================================================================
+// Legacy Compatibility
+// ============================================================================
+
+export const TOAST_TYPES = {
+ SUCCESS: "success",
+ ERROR: "error",
+ WARNING: "warning",
+ INFO: "info",
+} as const;
+
+// Legacy `type` can be either the key (e.g. "SUCCESS") or the value (e.g. "success")
+type LegacyToastType =
+ | keyof typeof TOAST_TYPES
+ | (typeof TOAST_TYPES)[keyof typeof TOAST_TYPES];
+
+interface CustomToastOptions {
+ message?: string;
+ type?: LegacyToastType;
+ duration?: number;
+}
+
+/**
+ * Legacy wrapper for backward compatibility
+ */
+const ReusableToast = ({
+ message = "",
+ type = "SUCCESS",
+ duration = DEFAULT_DURATION,
+}: CustomToastOptions = {}) => {
+ const variantMap: Record = {
+ SUCCESS: "success",
+ success: "success",
+ ERROR: "error",
+ error: "error",
+ WARNING: "warning",
+ warning: "warning",
+ INFO: "info",
+ info: "info",
+ };
+
+ const variant = variantMap[type] ?? "success";
+
+ // Map legacy message to title
+ return showToast({
+ title: message,
+ variant,
+ duration,
+ });
+};
+
+// ============================================================================
+// Exports
+// ============================================================================
+
+export { Toaster, toast, showToast };
+export type { ToastVariant, ToastOptions };
+export default ReusableToast;
diff --git a/src/vertex-template/components/theme-provider.tsx b/src/vertex-template/components/theme-provider.tsx
new file mode 100644
index 0000000000..f491def8a5
--- /dev/null
+++ b/src/vertex-template/components/theme-provider.tsx
@@ -0,0 +1,8 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({ children, ...props }: React.ComponentProps) {
+ return {children}
+}
diff --git a/src/vertex-template/components/ui/ErrorBoundary.tsx b/src/vertex-template/components/ui/ErrorBoundary.tsx
new file mode 100644
index 0000000000..deb8574ca7
--- /dev/null
+++ b/src/vertex-template/components/ui/ErrorBoundary.tsx
@@ -0,0 +1,33 @@
+import React, { Component, ErrorInfo, ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+}
+
+class ErrorBoundary extends Component {
+ public state: State = {
+ hasError: false,
+ };
+
+ public static getDerivedStateFromError(): State {
+ return { hasError: true };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error("Uncaught error:", error, errorInfo);
+ }
+
+ public render() {
+ if (this.state.hasError) {
+ return Sorry.. there was an error ;
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/src/vertex-template/components/ui/accordion.tsx b/src/vertex-template/components/ui/accordion.tsx
new file mode 100644
index 0000000000..073507d5ae
--- /dev/null
+++ b/src/vertex-template/components/ui/accordion.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(function AccordionItem({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+AccordionItem.displayName = "AccordionItem";
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/src/vertex-template/components/ui/alert-dialog.tsx b/src/vertex-template/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000..25e7b47446
--- /dev/null
+++ b/src/vertex-template/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/src/vertex-template/components/ui/alert.tsx b/src/vertex-template/components/ui/alert.tsx
new file mode 100644
index 0000000000..29bd44f01e
--- /dev/null
+++ b/src/vertex-template/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/src/vertex-template/components/ui/avatar.tsx b/src/vertex-template/components/ui/avatar.tsx
new file mode 100644
index 0000000000..68178b2e71
--- /dev/null
+++ b/src/vertex-template/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/src/vertex-template/components/ui/badge.tsx b/src/vertex-template/components/ui/badge.tsx
new file mode 100644
index 0000000000..9ec9a1a049
--- /dev/null
+++ b/src/vertex-template/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/src/vertex-template/components/ui/banner.tsx b/src/vertex-template/components/ui/banner.tsx
new file mode 100644
index 0000000000..d1f667579a
--- /dev/null
+++ b/src/vertex-template/components/ui/banner.tsx
@@ -0,0 +1,165 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import {
+ AqMessageCheckCircle,
+ AqMessageXCircle,
+ AqAlertTriangle,
+ AqAnnotationInfo,
+ AqXClose,
+} from '@airqo/icons-react';
+
+// ============================================================================
+// Types & Interfaces
+// ============================================================================
+
+export type BannerSeverity = 'success' | 'error' | 'warning' | 'info';
+
+export interface BannerProps {
+ severity: BannerSeverity;
+ title?: React.ReactNode;
+ message?: React.ReactNode;
+ icon?: React.ReactNode;
+ actions?: React.ReactNode;
+ dismissible?: boolean;
+ onDismiss?: () => void;
+ className?: string;
+ dense?: boolean;
+ showIcon?: boolean;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const SEVERITY_CONFIG: Record<
+ BannerSeverity,
+ {
+ bgColor: string;
+ borderColor: string;
+ textColor: string;
+ icon: React.ReactNode;
+ iconColor: string;
+ }
+> = {
+ success: {
+ bgColor: 'bg-emerald-100 dark:bg-emerald-900/40',
+ borderColor: 'border-emerald-400 dark:border-emerald-600',
+ textColor: 'text-emerald-900 dark:text-emerald-100',
+ icon: ,
+ iconColor: 'text-emerald-700 dark:text-emerald-300',
+ },
+ error: {
+ bgColor: 'bg-red-100 dark:bg-red-900/40',
+ borderColor: 'border-red-400 dark:border-red-600',
+ textColor: 'text-red-900 dark:text-red-100',
+ icon: ,
+ iconColor: 'text-red-700 dark:text-red-300',
+ },
+ warning: {
+ bgColor: 'bg-amber-100 dark:bg-amber-900/40',
+ borderColor: 'border-amber-400 dark:border-amber-600',
+ textColor: 'text-amber-900 dark:text-amber-100',
+ icon: ,
+ iconColor: 'text-amber-700 dark:text-amber-300',
+ },
+ info: {
+ bgColor: 'bg-blue-100 dark:bg-blue-950/20',
+ borderColor: 'border-blue-200 dark:border-blue-800',
+ textColor: 'text-blue-800 dark:text-blue-200',
+ icon: ,
+ iconColor: 'text-blue-600 dark:text-blue-400',
+ },
+};
+
+// ============================================================================
+// Banner Component
+// ============================================================================
+
+export const Banner: React.FC = ({
+ severity,
+ title,
+ message,
+ icon,
+ actions,
+ dismissible = false,
+ onDismiss,
+ className,
+ dense = false,
+ showIcon = true,
+}) => {
+ const config = SEVERITY_CONFIG[severity];
+ const displayIcon = icon || (showIcon ? config.icon : null);
+
+ const paddingClass = dense ? 'py-2 px-3' : 'p-4';
+
+ return (
+
+ {/* Icon */}
+ {displayIcon && (
+
+ {displayIcon}
+
+ )}
+
+ {/* Content */}
+
+ {title &&
{title} }
+ {message && (
+
{message}
+ )}
+ {actions && (
+
+ {actions}
+
+ )}
+
+
+ {/* Dismiss Button */}
+ {dismissible && onDismiss && (
+
+
+
+ )}
+
+ );
+};
+
+// ============================================================================
+// Convenience Components for Specific Severities
+// ============================================================================
+
+export const SuccessBanner: React.FC> = props => (
+
+);
+
+export const ErrorBanner: React.FC> = props => (
+
+);
+
+export const WarningBanner: React.FC> = props => (
+
+);
+
+export const InfoBanner: React.FC> = props => (
+
+);
diff --git a/src/vertex-template/components/ui/button.tsx b/src/vertex-template/components/ui/button.tsx
new file mode 100644
index 0000000000..36496a2872
--- /dev/null
+++ b/src/vertex-template/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/src/vertex-template/components/ui/calendar.tsx b/src/vertex-template/components/ui/calendar.tsx
new file mode 100644
index 0000000000..3cd65cc72b
--- /dev/null
+++ b/src/vertex-template/components/ui/calendar.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ (
+
+ ),
+ IconRight: ({ className, ...props }) => (
+
+ ),
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/src/vertex-template/components/ui/card.tsx b/src/vertex-template/components/ui/card.tsx
new file mode 100644
index 0000000000..3d806907a2
--- /dev/null
+++ b/src/vertex-template/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/vertex-template/components/ui/chart.tsx b/src/vertex-template/components/ui/chart.tsx
new file mode 100644
index 0000000000..32dc873f36
--- /dev/null
+++ b/src/vertex-template/components/ui/chart.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+})
+ChartContainer.displayName = "Chart"
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+